Merge remote-tracking branch 'origin/master' into patch-1

jh-changes
Shin'ya Ueoka 7 years ago
commit 9da2f5fd78
  1. 45
      .circleci/config.yml
  2. 4
      .eslintrc
  3. 1
      .gitignore
  4. 12
      .travis.yml
  5. 89
      QA.md
  6. 63
      README.md
  7. 28
      e2e/ambassador/manifest.json
  8. 42
      e2e/ambassador/src/background/index.js
  9. 7
      e2e/ambassador/src/background/ipc.js
  10. 26
      e2e/ambassador/src/background/tabs.js
  11. 29
      e2e/ambassador/src/client/ipc.js
  12. 31
      e2e/ambassador/src/client/keys.js
  13. 20
      e2e/ambassador/src/client/scrolls.js
  14. 37
      e2e/ambassador/src/client/tabs.js
  15. 27
      e2e/ambassador/src/client/windows.js
  16. 31
      e2e/ambassador/src/content/events.js
  17. 30
      e2e/ambassador/src/content/index.js
  18. 40
      e2e/ambassador/src/content/ipc.js
  19. 20
      e2e/ambassador/src/content/scrolls.js
  20. 34
      e2e/ambassador/src/shared/messages.js
  21. 24
      e2e/ambassador/webpack.config.js
  22. 151
      e2e/contents/scroll.test.js
  23. 216
      e2e/contents/tab.test.js
  24. 72
      e2e/contents/zoom.test.js
  25. 10
      e2e/karma-delay.js
  26. 53
      e2e/karma-webext-launcher.js
  27. 51
      e2e/karma.conf.js
  28. 14
      e2e/web-server/index.js
  29. 2
      karma.conf.js
  30. 5
      manifest.json
  31. 22240
      package-lock.json
  32. 43
      package.json
  33. 55
      script/package.js
  34. 0
      script/package.sh
  35. 79
      src/background/actions/command.js
  36. 10
      src/background/actions/find.js
  37. 8
      src/background/actions/index.js
  38. 8
      src/background/actions/operation.js
  39. 21
      src/background/actions/setting.js
  40. 18
      src/background/actions/tab.js
  41. 30
      src/background/components/background.js
  42. 1
      src/background/histories.js
  43. 4
      src/background/index.js
  44. 16
      src/background/reducers/find.js
  45. 5
      src/background/reducers/index.js
  46. 24
      src/background/reducers/setting.js
  47. 31
      src/background/tabs.js
  48. 8
      src/console/actions/console.js
  49. 1
      src/console/actions/index.js
  50. 45
      src/console/components/console.js
  51. 2
      src/console/index.js
  52. 4
      src/console/reducers/index.js
  53. 2
      src/console/site.scss
  54. 59
      src/content/actions/find.js
  55. 3
      src/content/actions/follow-controller.js
  56. 32
      src/content/actions/operation.js
  57. 9
      src/content/actions/setting.js
  58. 28
      src/content/components/common/follow.js
  59. 6
      src/content/components/common/input.js
  60. 3
      src/content/components/common/keymapper.js
  61. 13
      src/content/components/top-content/find.js
  62. 23
      src/content/components/top-content/follow-controller.js
  63. 9
      src/content/console-frames.js
  64. 13
      src/content/focuses.js
  65. 62
      src/content/navigates.js
  66. 2
      src/content/reducers/find.js
  67. 2
      src/content/reducers/follow-controller.js
  68. 177
      src/content/scrolls.js
  69. 26
      src/content/urls.js
  70. 31
      src/settings/actions/setting.js
  71. 52
      src/settings/components/form/blacklist-form.jsx
  72. 9
      src/settings/components/form/blacklist-form.scss
  73. 109
      src/settings/components/form/keymaps-form.jsx
  74. 11
      src/settings/components/form/keymaps-form.scss
  75. 60
      src/settings/components/form/properties-form.jsx
  76. 12
      src/settings/components/form/properties-form.scss
  77. 78
      src/settings/components/form/search-form.jsx
  78. 28
      src/settings/components/form/search-form.scss
  79. 202
      src/settings/components/index.jsx
  80. 19
      src/settings/components/site.scss
  81. 12
      src/settings/components/ui/add-button.jsx
  82. 13
      src/settings/components/ui/add-button.scss
  83. 12
      src/settings/components/ui/delete-button.jsx
  84. 13
      src/settings/components/ui/delete-button.scss
  85. 52
      src/settings/components/ui/input.jsx
  86. 29
      src/settings/components/ui/input.scss
  87. 5
      src/settings/index.jsx
  88. 2
      src/settings/reducers/setting.js
  89. 169
      src/shared/commands.js
  90. 84
      src/shared/commands/complete.js
  91. 3
      src/shared/commands/index.js
  92. 59
      src/shared/commands/parsers.js
  93. 3
      src/shared/messages.js
  94. 11
      src/shared/operations.js
  95. 15
      src/shared/settings/default.js
  96. 18
      src/shared/settings/properties.js
  97. 36
      src/shared/settings/storage.js
  98. 17
      src/shared/settings/validator.js
  99. 108
      src/shared/settings/values.js
  100. 13
      src/shared/store/provider.jsx
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,45 @@
version: 2
jobs:
build:
docker:
- image: circleci/node:9-stretch-browsers
environment:
- FIREFOX_VERSION: "59.0b9"
working_directory: ~
steps:
- restore_cache:
key: firefox-bin
paths:
- ~/firefox
- run:
name: Install Firefox
command: |
test -d ~/firefox/${FIREFOX_VERSION} && exit 0
url=https://ftp.mozilla.org/pub/firefox/releases/${FIREFOX_VERSION}/linux-x86_64/en-US/firefox-${FIREFOX_VERSION}.tar.bz2
curl -sSL -o- "$url" | tar xvfj -
mkdir -p ~/firefox
mv firefox ~/firefox/${FIREFOX_VERSION}
- save_cache:
key: firefox-bin
paths:
- ~/firefox
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-0 libdbus-glib-1-2
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
- run:
name: Install npm wee
command: npm install
- save_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
paths:
- node_modules
- run: echo 'export PATH=~/firefox/$FIREFOX_VERSION:$PATH' >> $BASH_ENV
- run: npm run lint
- run: npm test
- run: npm run package
- run: npm run build
- run: npm run ambassador:build
- run: node e2e/web-server & npm run test:e2e

@ -36,6 +36,7 @@
"newline-after-var": "off", "newline-after-var": "off",
"newline-before-return": "off", "newline-before-return": "off",
"newline-per-chained-call": "off", "newline-per-chained-call": "off",
"no-alert": "off",
"no-bitwise": "off", "no-bitwise": "off",
"no-console": ["error", { "allow": ["warn", "error"] }], "no-console": ["error", { "allow": ["warn", "error"] }],
"no-empty-function": "off", "no-empty-function": "off",
@ -44,6 +45,8 @@
"no-plusplus": "off", "no-plusplus": "off",
"no-ternary": "off", "no-ternary": "off",
"no-undefined": "off", "no-undefined": "off",
"no-undef-init": "off",
"no-unused-vars": ["error", { "varsIgnorePattern": "h" }],
"no-use-before-define": "off", "no-use-before-define": "off",
"no-warning-comments": "off", "no-warning-comments": "off",
"object-curly-newline": ["error", { "consistent": true }], "object-curly-newline": ["error", { "consistent": true }],
@ -65,5 +68,6 @@
"react/jsx-indent": ["error", 2], "react/jsx-indent": ["error", 2],
"react/prop-types": "off", "react/prop-types": "off",
"react/react-in-jsx-scope": "off"
} }
} }

1
.gitignore vendored

@ -1,3 +1,4 @@
/node_modules/ /node_modules/
/build/ /build/
/e2e/ambassador/build/
*.zip *.zip

@ -1,12 +0,0 @@
language: node_js
node_js:
- "6"
addons:
firefox: "56.0"
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
script:
- npm run lint
- npm test
- npm run package

89
QA.md

@ -1,17 +1,14 @@
## Checklist for testing Vim Vixen ## Checklist for testing Vim Vixen
### Operations ### Keybindings in JSON settings
Test operations with default key maps. Test operations with default key maps.
#### Scrolling #### Scrolling
- [ ] <kbd>k</kbd> or <kbd>Ctrl</kbd>+<kbd>Y</kbd>, <kbd>j</kbd> or <kbd>Ctrl</kbd>+<kbd>E</kbd>: scroll up and down - [ ] Smooth scroll by `:set smoothscroll`
- [ ] <kbd>h</kbd>, <kbd>l</kbd>: scroll left and right - [ ] Non-smooth scroll by `:set nosmoothscroll`
- [ ] <kbd>Ctrl</kbd>+<kbd>U</kbd>, <kbd>Ctrl</kbd>+<kbd>D</kbd>: scroll up and down by half of screen - [ ] Configure custom hint character by settings `"smoothscroll": true`, `"smoothscroll": false`
- [ ] <kbd>Ctrl</kbd>+<kbd>B</kbd>, <kbd>Ctrl</kbd>+<kbd>F</kbd>: scroll up and down by a screen
- [ ] <kbd>0</kbd>, <kbd>$</kbd>: scroll to leftmost and rightmost
- [ ] <kbd>g</kbd><kbd>g</kbd>, <kbd>G</kbd>: scroll to top and bottom
#### Console #### Console
@ -24,28 +21,26 @@ The behaviors of the console are tested in [Console section](#consoles).
#### Tabs #### Tabs
- [ ] <kbd>d</kbd>: delete current tab - [ ] <kbd>!d</kbd>: delete current tab and pinned tab
- [ ] <kbd>u</kbd>: reopen close tab - [ ] <kbd>u</kbd>: reopen close tab
- [ ] <kbd>K</kbd>, <kbd>J</kbd>: select prev and next tab
- [ ] <kbd>g0</kbd>, <kbd>g$</kbd>: select first and last tab
- [ ] <kbd>r</kbd>: reload current tab - [ ] <kbd>r</kbd>: reload current tab
- [ ] <kbd>R</kbd>: reload current tab without cache - [ ] <kbd>R</kbd>: reload current tab without cache
- [ ] <kbd>zd</kbd>: duplicate current tab
- [ ] <kbd>zp</kbd>: toggle pin/unpin state on current tab
#### Navigation #### Navigation
- [ ] <kbd>H</kbd>, <kbd>L</kbd>: go back and forward in histories - [ ] <kbd>H</kbd>, <kbd>L</kbd>: go back and forward in history
- [ ] <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: Open next/prev link in `<link>` tags.
- [ ] <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: find prev and next links and open it - [ ] <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: find prev and next links and open it
- [ ] <kbd>g</kbd><kbd>u</kbd>: go to parent directory - [ ] <kbd>g</kbd><kbd>u</kbd>: go to parent directory
- [ ] <kbd>g</kbd><kbd>U</kbd>: go to root directory - [ ] <kbd>g</kbd><kbd>U</kbd>: go to root directory
#### Misc #### Misc
- [ ] <kbd>z</kbd><kbd>i</kbd>, <kbd>z</kbd><kbd>o</kbd>: zoom-in and zoom-out
- [ ] <kbd>z</kbd><kbd>z</kbd>: set zoom level as default
- [ ] <kbd>y</kbd>: yank current URL and show a message - [ ] <kbd>y</kbd>: yank current URL and show a message
- [ ] <kbd>p</kbd>: open clipbord's URL in current tab
- [ ] <kbd>P</kbd>: open clipbord's URL in new tab
- [ ] Toggle enabled/disabled of plugin bu <kbd>Shift</kbd>+<kbd>Esc</kbd> - [ ] Toggle enabled/disabled of plugin bu <kbd>Shift</kbd>+<kbd>Esc</kbd>
- [ ] Hide error and info console by <kbd>Esc</kbd>
### Following links ### Following links
@ -59,6 +54,9 @@ The behaviors of the console are tested in [Console section](#consoles).
- [ ] Select link and open it in the frame in `<iframe>`/`<frame`> on following by <kbd>f</kbd> - [ ] Select link and open it in the frame in `<iframe>`/`<frame`> on following by <kbd>f</kbd>
- [ ] Select link and open it in new tab in `<iframe>`/`<frame`> on following by <kbd>F</kbd> - [ ] Select link and open it in new tab in `<iframe>`/`<frame`> on following by <kbd>F</kbd>
- [ ] Select link and open it in `<area>` tags, for <kbd>f</kbd> and <kbd>F</kbd> - [ ] Select link and open it in `<area>` tags, for <kbd>f</kbd> and <kbd>F</kbd>
- [ ] Configure custom hint character by `:set hintchars=012345678`
- [ ] Configure custom hint character by settings `"hintchars": "012345678"`
- [ ] Opened tabs is in child on Tree Style Tab
### Consoles ### Consoles
@ -82,7 +80,7 @@ The behaviors of the console are tested in [Console section](#consoles).
- [ ] `buffer`,`buffer<SP>`: do nothing - [ ] `buffer`,`buffer<SP>`: do nothing
- [ ] `buffer <title>`, `buffer <url>`: select tab which has an title matched with - [ ] `buffer <title>`, `buffer <url>`: select tab which has an title matched with
- [ ] `buffer 1`: select leftmost tab - [ ] `buffer 1`: select leftmost tab
- [ ] `buffer 0`, `buffer 99`: shows an error - [ ] `buffer 0`, `buffer <a number more than count of tabs>`: shows an error
- [ ] select tabs rotationally when more than two tabs are matched - [ ] select tabs rotationally when more than two tabs are matched
### Completions ### Completions
@ -109,20 +107,22 @@ The behaviors of the console are tested in [Console section](#consoles).
### Settings ### Settings
#### Validations #### JSON Settings
##### Validations
- [ ] show error on invalid json - [ ] show error on invalid json
- [ ] show error when top-level keys has keys other than `keymaps`, `search`, and `blacklist` - [ ] show error when top-level keys has keys other than `keymaps`, `search`, `blacklist`, and `properties`
##### `"keymaps"` section ###### `"keymaps"` section
- [ ] show error on unknown operation name in `"keymaps"` - [ ] show error on unknown operation name in `"keymaps"`
##### `"search"` section ###### `"search"` section
- validations in `"search"` section are not tested in this release - validations in `"search"` section are not tested in this release
#### `"blacklist"` section ##### `"blacklist"` section
- [ ] `github.com/a` blocks `github.com/a`, and not blocks `github.com/aa` - [ ] `github.com/a` blocks `github.com/a`, and not blocks `github.com/aa`
- [ ] `github.com/a*` blocks both `github.com/a` and `github.com/aa` - [ ] `github.com/a*` blocks both `github.com/a` and `github.com/aa`
@ -130,27 +130,66 @@ The behaviors of the console are tested in [Console section](#consoles).
- [ ] `github.com` blocks both `github.com/` and `github.com/a` - [ ] `github.com` blocks both `github.com/` and `github.com/a`
- [ ] `*.github.com` blocks `gist.github.com/`, and not `github.com` - [ ] `*.github.com` blocks `gist.github.com/`, and not `github.com`
#### Updating ##### Updating
- [ ] changes are updated on textarea blure when no errors - [ ] changes are updated on textarea blure when no errors
- [ ] changes are not updated on textarea blure when errors occurs - [ ] changes are not updated on textarea blure when errors occurs
- [ ] keymap settings are applied to open tabs without reload - [ ] keymap settings are applied to open tabs without reload
- [ ] search settings are applied to open tabs without reload - [ ] search settings are applied to open tabs without reload
##### Properties
- [ ] show errors when invalid property name
- [ ] show errors when invalid property type
#### Form Settings
<!-- validation on form settings does not implement in 0.7 -->
##### Search Engines
- [ ] able to change default
- [ ] able to remove item
- [ ] able to add item
##### `"blacklist"` section
- [ ] able to add item
- [ ] able to remove item
- [ ] `github.com/a` blocks `github.com/a`, and not blocks `github.com/aa`
- [ ] `github.com/a*` blocks both `github.com/a` and `github.com/aa`
- [ ] `github.com/` blocks `github.com/`, and not blocks `github.com/a`
- [ ] `github.com` blocks both `github.com/` and `github.com/a`
- [ ] `*.github.com` blocks `gist.github.com/`, and not `github.com`
##### Updating
- [ ] keymap settings are applied to open tabs without reload
- [ ] search settings are applied to open tabs without reload
### Settings source
- [ ] show confirmation dialog on switched from json to form
- [ ] state is saved on source changed
- [ ] on switching form -> json -> form, first and last form setting is equivalent to first one
### For certain sites ### For certain sites
- [ ] scoll on Hacker News - [ ] scroll on Hacker News
- [ ] able to scroll on Gmail and Slack - [ ] able to scroll on Gmail and Slack
- [ ] Fucus text box on Twitter or Slack, press <kbd>j</kbd>, then <kbd>j</kbd> is typed in the box - [ ] Focus text box on Twitter or Slack, press <kbd>j</kbd>, then <kbd>j</kbd> is typed in the box
- [ ] Focus the text box on Twitter or Slack on following mode - [ ] Focus the text box on Twitter or Slack on following mode
- [ ] Tha pages is shown in https://pitchify.com/
- [ ] Open console in http://www.espncricinfo.com/
## Find mode ## Find mode
- [ ] open console with <kbd>/</kbd> - [ ] open console with <kbd>/</kbd>
- [ ] highlight a word on <kbd>Enter</kb> pressed in find console - [ ] highlight a word on <kbd>Enter</kbd> pressed in find console
- [ ] Search next/prev by <kbd>n</kbd>/<kbd>N</kbd> - [ ] Search next/prev by <kbd>n</kbd>/<kbd>N</kbd>
- [ ] Wrap search by <kbd>n</kbd>/<kbd>N</kbd> - [ ] Wrap search by <kbd>n</kbd>/<kbd>N</kbd>
- [ ] Find with last keyword if keyword is empty - [ ] Find with last keyword if keyword is empty
- [ ] Find keyword last used on new tab opened
## Misc ## Misc

@ -1,5 +1,11 @@
# Vim Vixen # Vim Vixen
[![Greenkeeper badge](https://badges.greenkeeper.io/ueokande/vim-vixen.svg)](https://greenkeeper.io/)
[![Join the chat room on Gitter for vim-vixen/vim-vixen](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/vim-vixen/vim-vixen)
[![CircleCI](https://circleci.com/gh/ueokande/vim-vixen.svg?style=svg)](https://circleci.com/gh/ueokande/vim-vixen)
[![devDependencies Status](https://david-dm.org/ueokande/vim-vixen/dev-status.svg)](https://david-dm.org/ueokande/vim-vixen?type=dev)
Vim Vixen is a Firefox add-on which allows you to navigate with keyboard on the browser. Vim Vixen is a Firefox add-on which allows you to navigate with keyboard on the browser.
Firefox started to support WebExtensions API and will stop supporting add-ons using legacy APIs from version 57. Firefox started to support WebExtensions API and will stop supporting add-ons using legacy APIs from version 57.
For this reason, many legacy add-ons do not work on Firefox 57. For this reason, many legacy add-ons do not work on Firefox 57.
@ -19,38 +25,30 @@ The default mappings are as follows:
- <kbd>O</kbd>, <kbd>T</kbd>, <kbd>W</kbd>: similar to <kbd>o</kbd>, <kbd>t</kbd>, <kbd>w</kbd>, but that contains current URL - <kbd>O</kbd>, <kbd>T</kbd>, <kbd>W</kbd>: similar to <kbd>o</kbd>, <kbd>t</kbd>, <kbd>w</kbd>, but that contains current URL
- <kbd>b</kbd>: Select tabs by URL or title - <kbd>b</kbd>: Select tabs by URL or title
#### Scrolling
- <kbd>j</kbd>, <kbd>k</kbd>: scroll vertically
- <kbd>h</kbd>, <kbd>l</kbd>: scroll horizontally
- <kbd>Ctrl</kbd>+<kbd>U</kbd>, <kbd>Ctrl</kbd>+<kbd>D</kbd>: scroll pages by half of screen
- <kbd>Ctrl</kbd>+<kbd>B</kbd>, <kbd>Ctrl</kbd>+<kbd>F</kbd>: scroll pages by a screen
- <kbd>0</kbd>, <kbd>$</kbd>: scroll a page to leftmost/rightmost
- <kbd>g</kbd><kbd>g</kbd>, <kbd>G</kbd>: scroll to top/bottom
#### Tabs #### Tabs
- <kbd>d</kbd>: delete current tab - <kbd>!</kbd><kbd>d</kbd>: delete pinned tab
- <kbd>u</kbd>: reopen close tab - <kbd>u</kbd>: reopen close tab
- <kbd>K</kbd>, <kbd>J</kbd>: select prev or next tab
- <kbd>g0</kbd>, <kbd>g$</kbd>: select first or last tab
- <kbd>r</kbd>: reload current tab - <kbd>r</kbd>: reload current tab
- <kbd>R</kbd>: reload current tab without cache - <kbd>R</kbd>: reload current tab without cache
- <kbd>zp</kbd>: toggle pin/unpin current tab
- <kbd>zd</kbd>: duplicate current tab
### Navigation ### Navigation
- <kbd>f</kbd>: start following links in the page - <kbd>f</kbd>: start following links in the page
- <kbd>H</kbd>: go back in histories - <kbd>H</kbd>: go back in history
- <kbd>L</kbd>: go forward in histories - <kbd>L</kbd>: go forward in history
- <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: find prev or next links and open it - <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: find prev or next links and open it
- <kbd>g</kbd><kbd>u</kbd>: go to parent directory - <kbd>g</kbd><kbd>u</kbd>: go to parent directory
- <kbd>g</kbd><kbd>U</kbd>: go to root directory - <kbd>g</kbd><kbd>U</kbd>: go to root directory
- <kbd>g</kbd><kbd>i</kbd>: focus first input
#### Misc #### Misc
- <kbd>z</kbd><kbd>i</kbd>, <kbd>z</kbd><kbd>o</kbd>: zoom-in/zoom-out
- <kbd>z</kbd><kbd>z</kbd>: Set default zoom level
- <kbd>y</kbd>: copy URL in current tab - <kbd>y</kbd>: copy URL in current tab
- <kbd>p</kbd>: open clipbord's URL in current tab
- <kbd>P</kbd>: open clipbord's URL in new tab
- <kbd>Shift</kbd>+<kbd>Esc</kbd>: enable or disable the add-on in current tab. - <kbd>Shift</kbd>+<kbd>Esc</kbd>: enable or disable the add-on in current tab.
- <kbd>/</kbd>: start to find a keyword in the page
- <kbd>n</kbd>: find next keyword in the page
- <kbd>N</kbd>: find prev keyword in the page
### Console commands ### Console commands
@ -94,6 +92,35 @@ Open a URL or search keywords by search engine in new window.
Select tabs by URL or title matched by keywords. Select tabs by URL or title matched by keywords.
#### `:set` command
`:set` command can set properties on console.
##### `smoothscroll` property
Enable/disable smooth scroll.
```
:set smoothscroll " enable smooth scroll
:set nosmoothscroll " disable smooth scroll
```
##### `hintchars` property
Set hint characters
```
:set hintchars=0123456789
```
##### 'adjacenttab' property
Open a new tab on adjacent of the current tab.
```
:set noadjacenttab " open a tab at last
:set adjacenttab " open a tab adjacently
```
### Search engines ### Search engines
Vim Vixen supports search by search engines like Google and Yahoo. Vim Vixen supports search by search engines like Google and Yahoo.

@ -0,0 +1,28 @@
{
"manifest_version": 2,
"name": "ambassador",
"description": "WebExtension test helper",
"version": "0.1",
"content_scripts": [
{
"all_frames": true,
"matches": [ "<all_urls>" ],
"js": [ "build/content.js" ],
"run_at": "document_start",
"match_about_blank": true
}
],
"background": {
"scripts": [
"build/background.js"
]
},
"permissions": [
"history",
"sessions",
"storage",
"tabs",
"clipboardRead",
"activeTab"
]
}

@ -0,0 +1,42 @@
import {
WINDOWS_CREATE, WINDOWS_REMOVE, WINDOWS_GET,
TABS_CREATE, TABS_SELECT_AT, TABS_GET_ZOOM, TABS_SET_ZOOM,
EVENT_KEYPRESS, EVENT_KEYDOWN, EVENT_KEYUP,
SCROLL_GET, SCROLL_SET,
} from '../shared/messages';
import * as tabs from './tabs';
import { receiveContentMessage } from './ipc';
receiveContentMessage((message) => {
switch (message.type) {
case WINDOWS_CREATE:
return browser.windows.create({ url: message.url });
case WINDOWS_REMOVE:
return browser.windows.remove(message.windowId);
case WINDOWS_GET:
return browser.windows.get(message.windowId, { populate: true });
case TABS_CREATE:
return tabs.create({
url: message.url,
windowId: message.windowId,
});
case TABS_SELECT_AT:
return tabs.selectAt({
windowId: message.windowId,
index: message.index,
});
case TABS_GET_ZOOM:
return browser.tabs.getZoom(message.tabId);
case TABS_SET_ZOOM:
return browser.tabs.setZoom(message.tabId, message.factor);
case EVENT_KEYPRESS:
case EVENT_KEYDOWN:
case EVENT_KEYUP:
case SCROLL_GET:
case SCROLL_SET:
return browser.tabs.sendMessage(
message.tabId,
message
);
}
});

@ -0,0 +1,7 @@
const receiveContentMessage = (func) => {
browser.runtime.onMessage.addListener((message) => {
return func(message);
});
};
export { receiveContentMessage };

@ -0,0 +1,26 @@
const create = (props = {}) => {
return new Promise((resolve) => {
browser.tabs.create(props).then((createdTab) => {
let callback = (tabId, changeInfo, tab) => {
if (tab.url !== 'about:blank' && tabId === createdTab.id &&
changeInfo.status === 'complete') {
browser.tabs.onUpdated.removeListener(callback);
resolve(tab);
}
};
browser.tabs.onUpdated.addListener(callback);
});
});
};
const selectAt = (props = {}) => {
return browser.tabs.query({ windowId: props.windowId }).then((tabs) => {
let target = tabs[props.index];
return browser.tabs.update(target.id, { active: true });
});
};
export {
create, selectAt
};

@ -0,0 +1,29 @@
import { METHOD_REQUEST, METHOD_RESPONSE } from '../shared/messages';
const generateId = () => {
return Math.random().toString();
};
const send = (message) => {
return new Promise((resolve) => {
let id = generateId();
let callback = (e) => {
let packet = e.data;
if (e.source !== window || packet.method !== METHOD_RESPONSE ||
packet.id !== id) {
return;
}
window.removeEventListener('message', callback);
resolve(packet.message);
};
window.addEventListener('message', callback);
window.postMessage({
id,
method: METHOD_REQUEST,
message
}, window.origin);
});
};
export { send };

@ -0,0 +1,31 @@
import { EVENT_KEYPRESS, EVENT_KEYDOWN, EVENT_KEYUP } from '../shared/messages';
import * as ipc from './ipc';
const NEUTRAL_MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false };
const press = (tabId, key, modifiers = NEUTRAL_MODIFIERS) => {
return ipc.send(Object.assign({}, modifiers, {
type: EVENT_KEYPRESS,
tabId,
key,
}));
};
const down = (tabId, key, modifiers = NEUTRAL_MODIFIERS) => {
return ipc.send(Object.assign({}, modifiers, {
type: EVENT_KEYDOWN,
tabId,
key,
}));
};
const up = (tabId, key, modifiers = NEUTRAL_MODIFIERS) => {
return ipc.send(Object.assign({}, modifiers, {
type: EVENT_KEYUP,
tabId,
key,
}));
};
export { press, down, up };

@ -0,0 +1,20 @@
import { SCROLL_GET, SCROLL_SET } from '../shared/messages';
import * as ipc from './ipc';
const get = (tabId) => {
return ipc.send({
type: SCROLL_GET,
tabId,
});
};
const set = (tabId, x, y) => {
return ipc.send({
type: SCROLL_SET,
tabId,
x,
y,
});
};
export { get, set };

@ -0,0 +1,37 @@
import {
TABS_CREATE, TABS_SELECT_AT, TABS_GET_ZOOM, TABS_SET_ZOOM,
} from '../shared/messages';
import * as ipc from './ipc';
const create = (windowId, url) => {
return ipc.send({
type: TABS_CREATE,
windowId,
url,
});
};
const selectAt = (windowId, index) => {
return ipc.send({
type: TABS_SELECT_AT,
windowId,
index,
});
};
const getZoom = (tabId) => {
return ipc.send({
tabId,
type: TABS_GET_ZOOM,
});
};
const setZoom = (tabId, factor) => {
return ipc.send({
type: TABS_SET_ZOOM,
tabId,
factor,
});
};
export { create, selectAt, getZoom, setZoom };

@ -0,0 +1,27 @@
import {
WINDOWS_CREATE, WINDOWS_REMOVE, WINDOWS_GET
} from '../shared/messages';
import * as ipc from './ipc';
const create = (url) => {
return ipc.send({
type: WINDOWS_CREATE,
url,
});
};
const remove = (windowId) => {
return ipc.send({
type: WINDOWS_REMOVE,
windowId,
});
};
const get = (windowId) => {
return ipc.send({
type: WINDOWS_GET,
windowId,
});
};
export { create, remove, get };

@ -0,0 +1,31 @@
const keypress = (opts) => {
let event = new KeyboardEvent('keypress', {
key: opts.key,
altKey: opts.altKey,
shiftKey: opts.shiftKey,
ctrlKey: opts.ctrlKey
});
document.body.dispatchEvent(event);
};
const keydown = (opts) => {
let event = new KeyboardEvent('keydown', {
key: opts.key,
altKey: opts.altKey,
shiftKey: opts.shiftKey,
ctrlKey: opts.ctrlKey
});
document.body.dispatchEvent(event);
};
const keyup = (opts) => {
let event = new KeyboardEvent('keyup', {
key: opts.key,
altKey: opts.altKey,
shiftKey: opts.shiftKey,
ctrlKey: opts.ctrlKey
});
document.body.dispatchEvent(event);
};
export { keypress, keydown, keyup };

@ -0,0 +1,30 @@
import {
EVENT_KEYPRESS, EVENT_KEYDOWN, EVENT_KEYUP,
SCROLL_GET, SCROLL_SET,
} from '../shared/messages';
import * as ipc from './ipc';
import * as events from './events';
import * as scrolls from './scrolls';
ipc.receivePageMessage((message) => {
return ipc.sendToBackground(message);
});
ipc.receiveBackgroundMesssage((message) => {
switch (message.type) {
case EVENT_KEYPRESS:
events.keypress(message);
break;
case EVENT_KEYDOWN:
events.keydown(message);
break;
case EVENT_KEYUP:
events.keyup(message);
break;
case SCROLL_GET:
return Promise.resolve(scrolls.get());
case SCROLL_SET:
return Promise.resolve(scrolls.set(message.x, message.y));
}
return Promise.resolve({});
});

@ -0,0 +1,40 @@
import { METHOD_REQUEST, METHOD_RESPONSE } from '../shared/messages';
const sendToBackground = (message) => {
return browser.runtime.sendMessage(message);
};
const receiveBackgroundMesssage = (func) => {
return browser.runtime.onMessage.addListener((message) => {
return Promise.resolve(func(message));
});
};
const receivePageMessage = (func) => {
window.addEventListener('message', (e) => {
let packet = e.data;
if (e.origin !== window.origin || packet.method !== METHOD_REQUEST) {
return;
}
let resp = {
id: packet.id,
method: METHOD_RESPONSE,
};
let respMessage = func(packet.message);
if (respMessage instanceof Promise) {
return respMessage.then((data) => {
resp.message = data;
e.source.postMessage(resp, e.origin);
});
} else if (respMessage) {
resp.message = respMessage;
}
e.source.postMessage(resp, e.origin);
});
};
export {
sendToBackground, receiveBackgroundMesssage,
receivePageMessage,
};

@ -0,0 +1,20 @@
const get = () => {
let element = document.documentElement;
return {
xMax: element.scrollWidth - element.clientWidth,
yMax: element.scrollHeight - element.clientHeight,
x: element.scrollLeft,
y: element.scrollTop,
frameWidth: element.clientWidth,
frameHeight: element.clientHeight,
};
};
const set = (x, y) => {
let element = document.documentElement;
element.scrollLeft = x;
element.scrollTop = y;
return get();
};
export { get, set };

@ -0,0 +1,34 @@
const METHOD_REQUEST = 'request';
const METHOD_RESPONSE = 'response';
const WINDOWS_CREATE = 'windows.create';
const WINDOWS_REMOVE = 'windows.remove';
const WINDOWS_GET = 'windows.get';
const TABS_CREATE = 'tabs.create';
const TABS_SELECT_AT = 'tabs.selectAt';
const TABS_GET_ZOOM = 'tabs.get.zoom';
const TABS_SET_ZOOM = 'tabs.set.zoom';
const EVENT_KEYPRESS = 'event.keypress';
const EVENT_KEYDOWN = 'event.keydown';
const EVENT_KEYUP = 'event.keyup';
const SCROLL_GET = 'scroll.get';
const SCROLL_SET = 'scroll.set';
export {
METHOD_REQUEST,
METHOD_RESPONSE,
WINDOWS_CREATE,
WINDOWS_REMOVE,
WINDOWS_GET,
TABS_CREATE,
TABS_SELECT_AT,
TABS_GET_ZOOM,
TABS_SET_ZOOM,
EVENT_KEYPRESS,
EVENT_KEYDOWN,
EVENT_KEYUP,
SCROLL_GET,
SCROLL_SET,
};

@ -0,0 +1,24 @@
const path = require('path');
const src = path.resolve(__dirname, 'src');
const dist = path.resolve(__dirname, 'build');
config = {
entry: {
content: path.join(src, 'content'),
background: path.join(src, 'background')
},
output: {
path: dist,
filename: '[name].js'
},
resolve: {
extensions: [ '.js' ],
modules: [path.join(__dirname, 'src'), 'node_modules']
}
};
module.exports = config

@ -0,0 +1,151 @@
import { expect } from "chai";
import * as windows from "../ambassador/src/client/windows";
import * as tabs from "../ambassador/src/client/tabs";
import * as keys from "../ambassador/src/client/keys";
import * as scrolls from "../ambassador/src/client/scrolls";
const SERVER_URL = "localhost:11111";
describe("scroll test", () => {
let targetWindow;
let targetTab;
before(() => {
return windows.create().then((win) => {
targetWindow = win;
return tabs.create(targetWindow.id, SERVER_URL);
}).then((tab) => {
targetTab = tab;
});
});
after(() => {
return windows.remove(targetWindow.id);
});
it('scrolls up by k', () => {
let before
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'k');
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.be.lessThan(before.y);
});
});
it('scrolls down by j', () => {
let before
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'j');
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.be.greaterThan(before.y);
});
});
it('scrolls left by h', () => {
let before
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'h');
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.x).to.be.lessThan(before.x);
});
});
it('scrolls top by gg', () => {
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
return keys.press(targetTab.id, 'g');
}).then(() => {
return keys.press(targetTab.id, 'g');
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.be.equals(0);
});
});
it('scrolls bottom by G', () => {
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
return keys.press(targetTab.id, 'G', { shiftKey: true });
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.be.equals(actual.yMax);
});
});
it('scrolls bottom by 0', () => {
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
return keys.press(targetTab.id, '0');
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.x).to.be.equals(0);
});
});
it('scrolls bottom by $', () => {
return scrolls.set(targetTab.id, 100, 100).then((scroll) => {
return keys.press(targetTab.id, '$');
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.x).to.be.equals(actual.xMax);
});
});
it('scrolls bottom by <C-U>', () => {
let before
return scrolls.set(targetTab.id, 5000, 5000).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'u', { ctrlKey: true });
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.closeTo(before.y - before.frameHeight / 2, 1);
});
});
it('scrolls bottom by <C-D>', () => {
let before
return scrolls.set(targetTab.id, 5000, 5000).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'd', { ctrlKey: true });
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.closeTo(before.y + before.frameHeight / 2, 1);
});
});
it('scrolls bottom by <C-B>', () => {
let before
return scrolls.set(targetTab.id, 5000, 5000).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'b', { ctrlKey: true });
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.equals(before.y - before.frameHeight);
});
});
it('scrolls bottom by <C-F>', () => {
let before
return scrolls.set(targetTab.id, 5000, 5000).then((scroll) => {
before = scroll;
return keys.press(targetTab.id, 'f', { ctrlKey: true });
}).then(() => {
return scrolls.get(targetTab.id);
}).then((actual) => {
expect(actual.y).to.equals(before.y + before.frameHeight);
});
});
});

@ -0,0 +1,216 @@
import { expect } from "chai";
import * as windows from "../ambassador/src/client/windows";
import * as tabs from "../ambassador/src/client/tabs";
import * as keys from "../ambassador/src/client/keys";
const SERVER_URL = "localhost:11111/";
describe("tab test", () => {
let targetWindow;
beforeEach(() => {
return windows.create(SERVER_URL).then((win) => {
targetWindow = win;
});
});
afterEach(() => {
return windows.remove(targetWindow.id);
});
it('deletes tab by d', () => {
let before;
let targetTab;
return tabs.create(targetWindow.id, SERVER_URL).then((tab) => {
targetTab = tab;
return windows.get(targetWindow.id);
}).then((win) => {
before = win;
return keys.press(targetTab.id, 'd');
}).then(() => {
return windows.get(targetWindow.id);
}).then((actual) => {
expect(actual.tabs).to.have.lengthOf(before.tabs.length - 1);
});
});
it('duplicates tab by zd', () => {
let before;
let targetTab;
return tabs.create(targetWindow.id, SERVER_URL).then((tab) => {
targetTab = tab;
return windows.get(targetWindow.id)
}).then((win) => {;
before = win;
return keys.press(targetTab.id, 'z');
}).then(() => {
return keys.press(targetTab.id, 'd');
}).then(() => {
return windows.get(targetWindow.id);
}).then((actual) => {
expect(actual.tabs).to.have.lengthOf(before.tabs.length + 1);
});
})
it('makes pinned by zp', () => {
let before;
let targetTab;
return tabs.create(targetWindow.id, SERVER_URL).then((tab) => {
targetTab = tab;
return windows.get(targetWindow.id)
}).then((win) => {;
before = win;
return keys.press(targetTab.id, 'z');
}).then(() => {
return keys.press(targetTab.id, 'p');
}).then(() => {
return windows.get(targetWindow.id);
}).then((actual) => {
expect(actual.tabs[0].pinned).to.be.true;
});
})
it('selects previous tab by K', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 2);
}).then((tab) => {
return keys.press(tab.id, 'K', { shiftKey: true });
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[1].active).to.be.true;
});
});
it('selects previous tab by K rotatory', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 0);
}).then((tab) => {
return keys.press(tab.id, 'K', { shiftKey: true });
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[3].active).to.be.true;
});
});
it('selects next tab by J', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 2);
}).then((tab) => {
return keys.press(tab.id, 'J', { shiftKey: true });
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[3].active).to.be.true;
});
});
it('selects previous tab by J rotatory', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 3);
}).then((tab) => {
return keys.press(tab.id, 'J', { shiftKey: true });
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[0].active).to.be.true;
});
});
it('selects first tab by g0', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 2);
}).then((tab) => {
return keys.press(tab.id, 'g').then(() => tab);
}).then((tab) => {
return keys.press(tab.id, '0');
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[0].active).to.be.true;
});
});
it('selects last tab by g$', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 2);
}).then((tab) => {
return keys.press(tab.id, 'g').then(() => tab);
}).then((tab) => {
return keys.press(tab.id, '$');
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[3].active).to.be.true;
});
});
it('selects last selected tab by <C-6>', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#2')
}).then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#3');
}).then(() => {
return tabs.selectAt(targetWindow.id, 1);
}).then(() => {
return tabs.selectAt(targetWindow.id, 3);
}).then((tab) => {
return keys.press(tab.id, '6', { ctrlKey: true });
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs[1].active).to.be.true;
});
});
it('deletes tab by d', () => {
return Promise.resolve().then(() => {
return tabs.create(targetWindow.id, SERVER_URL + '#1');
}).then((tab) => {
return keys.press(tab.id, 'd');
}).then(() => {
return windows.get(targetWindow.id);
}).then((win) => {
expect(win.tabs).to.have.lengthOf(1);
});
});
});

@ -0,0 +1,72 @@
import { expect } from "chai";
import * as windows from "../ambassador/src/client/windows";
import * as tabs from "../ambassador/src/client/tabs";
import * as keys from "../ambassador/src/client/keys";
const SERVER_URL = "localhost:11111/";
describe("zoom test", () => {
let targetWindow;
let targetTab;
before(() => {
return windows.create(SERVER_URL).then((win) => {
targetWindow = win;
});
});
after(() => {
return windows.remove(targetWindow.id);
});
beforeEach(() => {
return tabs.create(targetWindow.id, SERVER_URL).then((tab) => {
targetTab = tab;
});
});
it('zooms-in by zi', () => {
let before;
return tabs.getZoom(targetTab.id).then((zoom) => {
before = zoom;
return keys.press(targetTab.id, 'z');
}).then(() => {
return keys.press(targetTab.id, 'i');
}).then(() => {
return tabs.getZoom(targetTab.id);
}).then((actual) => {
expect(actual).to.be.greaterThan(before);
});
});
it('zooms-in by zo', () => {
let before;
return tabs.getZoom(targetTab.id).then((zoom) => {
before = zoom;
return keys.press(targetTab.id, 'z');
}).then(() => {
return keys.press(targetTab.id, 'o');
}).then(() => {
return tabs.getZoom(targetTab.id);
}).then((actual) => {
expect(actual).to.be.lessThan(before);
});
});
it('zooms-in by zz', () => {
let before;
tabs.setZoom(targetTab.id, 1.5).then(() => {
return tabs.getZoom(targetTab.id);
}).then((zoom) => {
before = zoom;
return keys.press(targetTab.id, 'z');
}).then(() => {
return keys.press(targetTab.id, 'z');
}).then(() => {
return tabs.getZoom(targetTab.id);
}).then((actual) => {
expect(actual).to.be.lessThan(before);
expect(actual).to.be.be(1);
});
});
});

@ -0,0 +1,10 @@
'use strict';
window.__karma__.start = (function(start){
return function(){
var args = arguments
setTimeout(() => {
start(args)
}, 3000);
};
}(window.__karma__.start));

@ -0,0 +1,53 @@
'use strict'
var fs = require('fs')
var path = require('path')
var PREFS = {
'browser.shell.checkDefaultBrowser': 'false',
'browser.bookmarks.restore_default_bookmarks': 'false',
'dom.disable_open_during_load': 'false',
'dom.max_script_run_time': '0',
'dom.min_background_timeout_value': '10',
'extensions.autoDisableScopes': '0',
'extensions.enabledScopes': '15',
}
var FirefoxWebExt = function (id, baseBrowserDecorator, args) {
baseBrowserDecorator(this)
this._start = function (url) {
var self = this
var command = this._getCommand()
let prefArgs = [].concat(...Object.keys(PREFS).map((key) => {
return ['--pref', key + '=' + PREFS[key]];
}));
let sourceDirArgs = [].concat(...args.sourceDirs.map((dir) => {
return ['--source-dir', dir];
}));
self._execCommand(
command,
['run', '--start-url', url, '--no-input'].concat(sourceDirArgs, prefArgs)
)
}
}
FirefoxWebExt.prototype = {
name: 'FirefoxWebExt',
DEFAULT_CMD: {
linux: 'node_modules/web-ext/bin/web-ext',
darwin: 'node_modules/web-ext/bin/web-ext',
win32: 'node_modules/web-ext/bin/web-ext',
}
}
FirefoxWebExt.$inject = ['id', 'baseBrowserDecorator', 'args']
// PUBLISH DI MODULE
module.exports = {
'launcher:FirefoxWebExt': ['type', FirefoxWebExt],
}

@ -0,0 +1,51 @@
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['mocha'],
files: [
'karma-delay.js',
'**/*.test.js'
],
preprocessors: {
'**/*.test.js': ['webpack']
},
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
customLaunchers: {
FirefoxWebExtRunner: {
base: 'FirefoxWebExt',
sourceDirs: [ '.', 'e2e/ambassador'],
},
},
browsers: ['FirefoxWebExtRunner'],
sauceLabs: {
username: 'michael_jackson'
},
singleRun: true,
webpackMiddleware: {
noInfo: true
},
reporters: ['mocha'],
plugins: [
require('./karma-webext-launcher'),
'karma-mocha',
'karma-webpack',
'karma-mocha-reporter',
],
client: {
mocha: {
timeout: 5000
}
}
})
}

@ -0,0 +1,14 @@
var http = require('http');
const content =
'<!DOCTYPE html>' +
'<html lang="en">' +
'<body style="width:10000px; height:10000px">' +
'</body>' +
'</html">' ;
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(content);
}).listen(11111, '127.0.0.1');

@ -7,11 +7,13 @@ module.exports = function (config) {
frameworks: ['mocha'], frameworks: ['mocha'],
files: [ files: [
'test/**/*.test.js', 'test/**/*.test.js',
'test/**/*.test.jsx',
'test/**/*.html' 'test/**/*.html'
], ],
preprocessors: { preprocessors: {
'test/**/*.test.js': [ 'webpack' ], 'test/**/*.test.js': [ 'webpack' ],
'test/**/*.test.jsx': [ 'webpack' ],
'test/**/*.html': ['html2js'] 'test/**/*.html': ['html2js']
}, },

@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "Vim Vixen", "name": "Vim Vixen",
"description": "Vim Vixen", "description": "Vim Vixen",
"version": "0.5", "version": "0.12",
"icons": { "icons": {
"48": "resources/icon_48x48.png", "48": "resources/icon_48x48.png",
"96": "resources/icon_96x96.png" "96": "resources/icon_96x96.png"
@ -30,7 +30,8 @@
"history", "history",
"sessions", "sessions",
"storage", "storage",
"tabs" "tabs",
"clipboardRead"
], ],
"web_accessible_resources": [ "web_accessible_resources": [
"build/console.html", "build/console.html",

22240
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -2,12 +2,14 @@
"name": "vim-vixen", "name": "vim-vixen",
"description": "Vim vixen", "description": "Vim vixen",
"scripts": { "scripts": {
"start": "webpack -w --debug --devtool inline-source-map", "start": "webpack --mode development -w --debug --devtool inline-source-map",
"lint": "eslint --ext .jsx,.js src", "build": "NODE_ENV=production webpack --mode production --progress --display-error-details",
"build": "NODE_ENV=production webpack --progress --display-error-details", "package": "npm run build && script/package.sh",
"package": "npm run build && ./package.sh", "lint": "eslint --ext .jsx,.js src e2e/ambassador/src",
"lint": "eslint src", "test": "karma start",
"test": "karma start" "test:e2e": "karma start e2e/karma.conf.js",
"ambassador:start": "webpack --mode production -w --debug --context e2e/ambassador --config e2e/ambassador/webpack.config.js --devtool inline-source-map",
"ambassador:build": "webpack --mode production --context e2e/ambassador --config e2e/ambassador/webpack.config.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -20,31 +22,30 @@
}, },
"homepage": "https://github.com/ueokande/vim-vixen", "homepage": "https://github.com/ueokande/vim-vixen",
"devDependencies": { "devDependencies": {
"adm-zip": "^0.4.7",
"babel-cli": "^6.24.1", "babel-cli": "^6.24.1",
"babel-eslint": "^7.2.3", "babel-eslint": "^8.0.2",
"babel-loader": "^7.1.1", "babel-loader": "^7.1.1",
"babel-minify-webpack-plugin": "^0.2.0", "babel-preset-preact": "^1.1.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"chai": "^4.1.1", "chai": "^4.1.1",
"css-loader": "^0.28.4", "css-loader": "^0.28.10",
"eslint": "^4.7.0", "eslint": "^4.7.0",
"eslint-plugin-react": "^7.4.0", "eslint-plugin-react": "^7.4.0",
"html-webpack-plugin": "^2.30.1", "html-webpack-plugin": "^3.0.6",
"karma": "^1.7.0", "karma": "^2.0.0",
"karma-firefox-launcher": "^1.0.1", "karma-firefox-launcher": "^1.0.1",
"karma-html2js-preprocessor": "^1.1.0", "karma-html2js-preprocessor": "^1.1.0",
"karma-mocha": "^1.3.0", "karma-mocha": "^1.3.0",
"karma-mocha-reporter": "^2.2.3", "karma-mocha-reporter": "^2.2.3",
"karma-sourcemap-loader": "^0.3.7", "karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^2.0.4", "karma-webpack": "^3.0.0",
"mocha": "^3.5.0", "mocha": "^5.0.0",
"node-sass": "^4.5.3", "node-sass": "^4.5.3",
"react": "^16.0.0", "preact": "^8.2.6",
"react-dom": "^16.0.0", "sass-loader": "^7.0.1",
"sass-loader": "^6.0.6", "style-loader": "^0.21.0",
"style-loader": "^0.18.2", "web-ext": "github:ueokande/web-ext#patched-2.4.0",
"webpack": "^3.5.3" "webpack": "^4.1.1",
"webpack-cli": "^2.0.11"
} }
} }

@ -0,0 +1,55 @@
let path = require('path');
let fs = require('fs');
let AdmZip = require('adm-zip');
let manifest = require('../manifest');
manifest.iconFiles = function() {
return Object.keys(this.icons).map(key => this.icons[key]);
};
manifest.contentScriptFiles = function() {
let files = this.content_scripts.map(entry => entry.js);
return [].concat.apply([], files);
};
manifest.backgroundScriptFiles = function() {
return this.background.scripts;
};
manifest.webAccessibleResourceFiles = function() {
return this.web_accessible_resources;
};
manifest.optionFiles = function() {
let uiFile = this.options_ui.page;
let dir = path.dirname(uiFile);
let html = fs.readFileSync(uiFile, 'utf-8');
let files = [uiFile];
let regex = /<\s*script\s+src\s*=\s*'(.*)'\s*>/g;
let match = regex.exec(html);
while (match) {
files.push(path.join(dir, match[1]));
match = regex.exec(html);
}
return files;
};
let files = []
.concat('manifest.json')
.concat(manifest.iconFiles())
.concat(manifest.contentScriptFiles())
.concat(manifest.backgroundScriptFiles())
.concat(manifest.webAccessibleResourceFiles())
.concat(manifest.optionFiles());
let zip = new AdmZip();
let output = `${manifest.version}.zip`;
console.log(output);
for (let f of files) {
let dir = path.dirname(f);
zip.addLocalFile(f, dir);
console.log('=>', path.join(dir, f));
}
zip.writeZip(output);

@ -0,0 +1,79 @@
import actions from '../actions';
import * as tabs from 'background/tabs';
import * as parsers from 'shared/commands/parsers';
import * as properties from 'shared/settings/properties';
const openCommand = (url) => {
return browser.tabs.query({
active: true, currentWindow: true
}).then((gotTabs) => {
if (gotTabs.length > 0) {
return browser.tabs.update(gotTabs[0].id, { url: url });
}
});
};
const tabopenCommand = (url) => {
return browser.tabs.create({ url: url });
};
const winopenCommand = (url) => {
return browser.windows.create({ url });
};
const bufferCommand = (keywords) => {
if (keywords.length === 0) {
return Promise.resolve([]);
}
let keywordsStr = keywords.join(' ');
return browser.tabs.query({
active: true, currentWindow: true
}).then((gotTabs) => {
if (gotTabs.length > 0) {
if (isNaN(keywordsStr)) {
return tabs.selectByKeyword(gotTabs[0], keywordsStr);
}
let index = parseInt(keywordsStr, 10) - 1;
return tabs.selectAt(index);
}
});
};
const setCommand = (args) => {
if (!args[0]) {
return Promise.resolve();
}
let [name, value] = parsers.parseSetOption(args[0], properties.types);
return {
type: actions.SETTING_SET_PROPERTY,
name,
value
};
};
const exec = (line, settings) => {
let [name, args] = parsers.parseCommandLine(line);
switch (name) {
case 'o':
case 'open':
return openCommand(parsers.normalizeUrl(args, settings.search));
case 't':
case 'tabopen':
return tabopenCommand(parsers.normalizeUrl(args, settings.search));
case 'w':
case 'winopen':
return winopenCommand(parsers.normalizeUrl(args, settings.search));
case 'b':
case 'buffer':
return bufferCommand(args);
case 'set':
return setCommand(args);
case '':
return Promise.resolve();
}
throw new Error(name + ' command is not defined');
};
export { exec };

@ -0,0 +1,10 @@
import actions from './index';
const setKeyword = (keyword) => {
return {
type: actions.FIND_SET_KEYWORD,
keyword,
};
};
export { setKeyword };

@ -0,0 +1,8 @@
export default {
// Settings
SETTING_SET_SETTINGS: 'setting.set.settings',
SETTING_SET_PROPERTY: 'setting.set.property',
// Find
FIND_SET_KEYWORD: 'find.set.keyword',
};

@ -17,6 +17,8 @@ const exec = (operation, tab) => {
switch (operation.type) { switch (operation.type) {
case operations.TAB_CLOSE: case operations.TAB_CLOSE:
return tabs.closeTab(tab.id); return tabs.closeTab(tab.id);
case operations.TAB_CLOSE_FORCE:
return tabs.closeTabForce(tab.id);
case operations.TAB_REOPEN: case operations.TAB_REOPEN:
return tabs.reopenTab(); return tabs.reopenTab();
case operations.TAB_PREV: case operations.TAB_PREV:
@ -27,6 +29,8 @@ const exec = (operation, tab) => {
return tabs.selectFirstTab(); return tabs.selectFirstTab();
case operations.TAB_LAST: case operations.TAB_LAST:
return tabs.selectLastTab(); return tabs.selectLastTab();
case operations.TAB_PREV_SEL:
return tabs.selectPrevSelTab();
case operations.TAB_RELOAD: case operations.TAB_RELOAD:
return tabs.reload(tab, operation.cache); return tabs.reload(tab, operation.cache);
case operations.TAB_PIN: case operations.TAB_PIN:
@ -69,6 +73,10 @@ const exec = (operation, tab) => {
return browser.tabs.sendMessage(tab.id, { return browser.tabs.sendMessage(tab.id, {
type: messages.CONSOLE_SHOW_FIND type: messages.CONSOLE_SHOW_FIND
}); });
case operations.CANCEL:
return browser.tabs.sendMessage(tab.id, {
type: messages.CONSOLE_HIDE,
});
default: default:
return Promise.resolve(); return Promise.resolve();
} }

@ -0,0 +1,21 @@
import actions from '../actions';
import * as settingsStorage from 'shared/settings/storage';
const load = () => {
return settingsStorage.loadValue().then((value) => {
return {
type: actions.SETTING_SET_SETTINGS,
value,
};
});
};
const setProperty = (name, value) => {
return {
type: actions.SETTING_SET_PROPERTY,
name,
value,
};
};
export { load, setProperty };

@ -1,9 +1,21 @@
const openNewTab = (url) => { const openNewTab = (url, openerTabId, background = false, adjacent = false) => {
return browser.tabs.create({ url: url }); if (adjacent) {
return browser.tabs.query({
active: true, currentWindow: true
}).then((tabs) => {
return browser.tabs.create({
url,
openerTabId,
active: !background,
index: tabs[0].index + 1
});
});
}
return browser.tabs.create({ url, active: !background });
}; };
const openToTab = (url, tab) => { const openToTab = (url, tab) => {
return browser.tabs.update(tab.id, { url: url }); return browser.tabs.update(tab.id, { url: url });
}; };
export { openToTab, openNewTab }; export { openNewTab, openToTab };

@ -1,6 +1,8 @@
import messages from 'shared/messages'; import messages from 'shared/messages';
import * as operationActions from 'background/actions/operation'; import * as operationActions from 'background/actions/operation';
import * as settingsActions from 'settings/actions/setting'; import * as commandActions from 'background/actions/command';
import * as settingActions from 'background/actions/setting';
import * as findActions from 'background/actions/find';
import * as tabActions from 'background/actions/tab'; import * as tabActions from 'background/actions/tab';
import * as commands from 'shared/commands'; import * as commands from 'shared/commands';
@ -22,6 +24,8 @@ export default class BackgroundComponent {
onMessage(message, sender) { onMessage(message, sender) {
let settings = this.store.getState().setting; let settings = this.store.getState().setting;
let find = this.store.getState().find;
switch (message.type) { switch (message.type) {
case messages.BACKGROUND_OPERATION: case messages.BACKGROUND_OPERATION:
return this.store.dispatch( return this.store.dispatch(
@ -29,25 +33,31 @@ export default class BackgroundComponent {
sender); sender);
case messages.OPEN_URL: case messages.OPEN_URL:
if (message.newTab) { if (message.newTab) {
return this.store.dispatch( let action = tabActions.openNewTab(
tabActions.openNewTab(message.url), sender); message.url, sender.tab.id, message.background,
settings.value.properties.adjacenttab);
return this.store.dispatch(action, sender);
} }
return this.store.dispatch( return this.store.dispatch(
tabActions.openToTab(message.url, sender.tab), sender); tabActions.openToTab(message.url, sender.tab), sender);
case messages.CONSOLE_ENTER_COMMAND: case messages.CONSOLE_ENTER_COMMAND:
return commands.exec(message.text, settings.value).catch((e) => { this.store.dispatch(
return browser.tabs.sendMessage(sender.tab.id, { commandActions.exec(message.text, settings.value),
type: messages.CONSOLE_SHOW_ERROR, sender
text: e.message, );
}); return this.broadcastSettingsChanged();
});
case messages.SETTINGS_QUERY: case messages.SETTINGS_QUERY:
return Promise.resolve(this.store.getState().setting.value); return Promise.resolve(this.store.getState().setting.value);
case messages.CONSOLE_QUERY_COMPLETIONS: case messages.CONSOLE_QUERY_COMPLETIONS:
return commands.complete(message.text, settings.value); return commands.complete(message.text, settings.value);
case messages.SETTINGS_RELOAD: case messages.SETTINGS_RELOAD:
this.store.dispatch(settingsActions.load()); this.store.dispatch(settingActions.load());
return this.broadcastSettingsChanged(); return this.broadcastSettingsChanged();
case messages.FIND_GET_KEYWORD:
return Promise.resolve(find.keyword);
case messages.FIND_SET_KEYWORD:
this.store.dispatch(findActions.setKeyword(message.keyword));
return Promise.resolve({});
} }
} }

@ -76,7 +76,6 @@ const getCompletions = (keyword) => {
.sort((x, y) => x[0].visitCount < y[0].visitCount) .sort((x, y) => x[0].visitCount < y[0].visitCount)
.slice(0, 10) .slice(0, 10)
.map(item => item[0]) .map(item => item[0])
.sort((x, y) => x.url > y.url)
)[0]; )[0];
}); });
}; };

@ -1,4 +1,4 @@
import * as settingsActions from 'settings/actions/setting'; import * as settingActions from 'background/actions/setting';
import messages from 'shared/messages'; import messages from 'shared/messages';
import BackgroundComponent from 'background/components/background'; import BackgroundComponent from 'background/components/background';
import reducers from 'background/reducers'; import reducers from 'background/reducers';
@ -16,4 +16,4 @@ const store = createStore(reducers, (e, sender) => {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const backgroundComponent = new BackgroundComponent(store); const backgroundComponent = new BackgroundComponent(store);
store.dispatch(settingsActions.load()); store.dispatch(settingActions.load());

@ -0,0 +1,16 @@
import actions from 'content/actions';
const defaultState = {
keyword: null,
};
export default function reducer(state = defaultState, action = {}) {
switch (action.type) {
case actions.FIND_SET_KEYWORD:
return Object.assign({}, state, {
keyword: action.keyword,
});
default:
return state;
}
}

@ -1,12 +1,15 @@
import settingReducer from 'settings/reducers/setting'; import settingReducer from './setting';
import findReducer from './find';
// Make setting reducer instead of re-use // Make setting reducer instead of re-use
const defaultState = { const defaultState = {
setting: settingReducer(undefined, {}), setting: settingReducer(undefined, {}),
find: findReducer(undefined, {}),
}; };
export default function reducer(state = defaultState, action = {}) { export default function reducer(state = defaultState, action = {}) {
return Object.assign({}, state, { return Object.assign({}, state, {
setting: settingReducer(state.setting, action), setting: settingReducer(state.setting, action),
find: findReducer(state.find, action),
}); });
} }

@ -0,0 +1,24 @@
import actions from 'background/actions';
const defaultState = {
value: {},
};
export default function reducer(state = defaultState, action = {}) {
switch (action.type) {
case actions.SETTING_SET_SETTINGS:
return {
value: action.value,
};
case actions.SETTING_SET_PROPERTY:
return {
value: Object.assign({}, state.value, {
properties: Object.assign({}, state.value.properties,
{ [action.name]: action.value })
})
};
default:
return state;
}
}

@ -1,4 +1,22 @@
let prevSelTab = 1;
let currSelTab = 1;
browser.tabs.onActivated.addListener((activeInfo) => {
return browser.tabs.query({ currentWindow: true }).then(() => {
prevSelTab = currSelTab;
currSelTab = activeInfo.tabId;
});
});
const closeTab = (id) => { const closeTab = (id) => {
return browser.tabs.get(id).then((tab) => {
if (!tab.pinned) {
return browser.tabs.remove(id);
}
});
};
const closeTabForce = (id) => {
return browser.tabs.remove(id); return browser.tabs.remove(id);
}; };
@ -51,7 +69,7 @@ const selectByKeyword = (current, keyword) => {
const getCompletions = (keyword) => { const getCompletions = (keyword) => {
return browser.tabs.query({ currentWindow: true }).then((tabs) => { return browser.tabs.query({ currentWindow: true }).then((tabs) => {
let matched = tabs.filter((t) => { let matched = tabs.filter((t) => {
return t.url.includes(keyword) || t.title.includes(keyword); return t.url.includes(keyword) || t.title && t.title.includes(keyword);
}); });
return matched; return matched;
}); });
@ -93,6 +111,10 @@ const selectLastTab = () => {
}); });
}; };
const selectPrevSelTab = () => {
return browser.tabs.update(prevSelTab, { active: true });
};
const reload = (current, cache) => { const reload = (current, cache) => {
return browser.tabs.reload( return browser.tabs.reload(
current.id, current.id,
@ -116,7 +138,8 @@ const duplicate = (id) => {
}; };
export { export {
closeTab, reopenTab, selectAt, selectByKeyword, getCompletions, closeTab, closeTabForce, reopenTab, selectAt, selectByKeyword,
selectPrevTab, selectNextTab, selectFirstTab, selectLastTab, reload, getCompletions, selectPrevTab, selectNextTab, selectFirstTab,
updateTabPinned, toggleTabPinned, duplicate selectLastTab, selectPrevSelTab, reload, updateTabPinned,
toggleTabPinned, duplicate
}; };

@ -1,5 +1,11 @@
import actions from 'console/actions'; import actions from 'console/actions';
const hide = () => {
return {
type: actions.CONSOLE_HIDE,
};
};
const showCommand = (text) => { const showCommand = (text) => {
return { return {
type: actions.CONSOLE_SHOW_COMMAND, type: actions.CONSOLE_SHOW_COMMAND,
@ -61,6 +67,6 @@ const completionPrev = () => {
}; };
export { export {
showCommand, showFind, showError, showInfo, hideCommand, setConsoleText, hide, showCommand, showFind, showError, showInfo, hideCommand, setConsoleText,
setCompletions, completionNext, completionPrev setCompletions, completionNext, completionPrev
}; };

@ -1,5 +1,6 @@
export default { export default {
// console commands // console commands
CONSOLE_HIDE: 'console.hide',
CONSOLE_SHOW_COMMAND: 'console.show.command', CONSOLE_SHOW_COMMAND: 'console.show.command',
CONSOLE_SHOW_ERROR: 'console.show.error', CONSOLE_SHOW_ERROR: 'console.show.error',
CONSOLE_SHOW_INFO: 'console.show.info', CONSOLE_SHOW_INFO: 'console.show.info',

@ -31,14 +31,33 @@ export default class ConsoleComponent {
} }
} }
doEnter(e) {
e.stopPropagation();
e.preventDefault();
return this.onEntered(e.target.value);
}
selectNext(e) {
this.store.dispatch(consoleActions.completionNext());
e.stopPropagation();
e.preventDefault();
}
selectPrev(e) {
this.store.dispatch(consoleActions.completionPrev());
e.stopPropagation();
e.preventDefault();
}
onKeyDown(e) { onKeyDown(e) {
if (e.keyCode === KeyboardEvent.DOM_VK_ESCAPE && e.ctrlKey) {
return this.hideCommand();
}
switch (e.keyCode) { switch (e.keyCode) {
case KeyboardEvent.DOM_VK_ESCAPE: case KeyboardEvent.DOM_VK_ESCAPE:
return this.hideCommand(); return this.hideCommand();
case KeyboardEvent.DOM_VK_RETURN: case KeyboardEvent.DOM_VK_RETURN:
e.stopPropagation(); return this.doEnter(e);
e.preventDefault();
return this.onEntered(e.target.value);
case KeyboardEvent.DOM_VK_TAB: case KeyboardEvent.DOM_VK_TAB:
if (e.shiftKey) { if (e.shiftKey) {
this.store.dispatch(consoleActions.completionPrev()); this.store.dispatch(consoleActions.completionPrev());
@ -48,6 +67,26 @@ export default class ConsoleComponent {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
break; break;
case KeyboardEvent.DOM_VK_OPEN_BRACKET:
if (e.ctrlKey) {
return this.hideCommand();
}
break;
case KeyboardEvent.DOM_VK_M:
if (e.ctrlKey) {
return this.doEnter(e);
}
break;
case KeyboardEvent.DOM_VK_N:
if (e.ctrlKey) {
this.selectNext(e);
}
break;
case KeyboardEvent.DOM_VK_P:
if (e.ctrlKey) {
this.selectPrev(e);
}
break;
} }
} }

@ -24,6 +24,8 @@ const onMessage = (message) => {
return store.dispatch(consoleActions.showError(message.text)); return store.dispatch(consoleActions.showError(message.text));
case messages.CONSOLE_SHOW_INFO: case messages.CONSOLE_SHOW_INFO:
return store.dispatch(consoleActions.showInfo(message.text)); return store.dispatch(consoleActions.showInfo(message.text));
case messages.CONSOLE_HIDE:
return store.dispatch(consoleActions.hide());
} }
}; };

@ -53,6 +53,10 @@ const nextConsoleText = (completions, group, item, defaults) => {
export default function reducer(state = defaultState, action = {}) { export default function reducer(state = defaultState, action = {}) {
switch (action.type) { switch (action.type) {
case actions.CONSOLE_HIDE:
return Object.assign({}, state, {
mode: '',
});
case actions.CONSOLE_SHOW_COMMAND: case actions.CONSOLE_SHOW_COMMAND:
return Object.assign({}, state, { return Object.assign({}, state, {
mode: 'command', mode: 'command',

@ -69,6 +69,8 @@ body {
&-message { &-message {
@include consoole-font; @include consoole-font;
border-top: 1px solid gray;
} }
&-error { &-error {

@ -5,6 +5,7 @@
// NOTE: window.find is not standard API // NOTE: window.find is not standard API
// https://developer.mozilla.org/en-US/docs/Web/API/Window/find // https://developer.mozilla.org/en-US/docs/Web/API/Window/find
import messages from 'shared/messages';
import actions from 'content/actions'; import actions from 'content/actions';
import * as consoleFrames from '../console-frames'; import * as consoleFrames from '../console-frames';
@ -14,6 +15,13 @@ const postPatternNotFound = (pattern) => {
'Pattern not found: ' + pattern); 'Pattern not found: ' + pattern);
}; };
const postPatternFound = (pattern) => {
return consoleFrames.postInfo(
window.document,
'Pattern found: ' + pattern,
);
};
const find = (string, backwards) => { const find = (string, backwards) => {
let caseSensitive = false; let caseSensitive = false;
let wrapScan = true; let wrapScan = true;
@ -24,32 +32,49 @@ const find = (string, backwards) => {
return window.find(string, caseSensitive, backwards, wrapScan); return window.find(string, caseSensitive, backwards, wrapScan);
}; };
const findNext = (keyword, reset, backwards) => { const findNext = (currentKeyword, reset, backwards) => {
if (reset) { if (reset) {
window.getSelection().removeAllRanges(); window.getSelection().removeAllRanges();
} }
let found = find(keyword, backwards); let promise = Promise.resolve(currentKeyword);
if (!found) { if (currentKeyword) {
window.getSelection().removeAllRanges(); browser.runtime.sendMessage({
found = find(keyword, backwards); type: messages.FIND_SET_KEYWORD,
keyword: currentKeyword,
});
} else {
promise = browser.runtime.sendMessage({
type: messages.FIND_GET_KEYWORD,
});
} }
if (!found) {
postPatternNotFound(keyword); return promise.then((keyword) => {
} let found = find(keyword, backwards);
return { if (!found) {
type: actions.FIND_SET_KEYWORD, window.getSelection().removeAllRanges();
keyword, found = find(keyword, backwards);
found, }
}; if (found) {
postPatternFound(keyword);
} else {
postPatternNotFound(keyword);
}
return {
type: actions.FIND_SET_KEYWORD,
keyword,
found,
};
});
}; };
const next = (keyword, reset) => { const next = (currentKeyword, reset) => {
return findNext(keyword, reset, false); return findNext(currentKeyword, reset, false);
}; };
const prev = (keyword, reset) => { const prev = (currentKeyword, reset) => {
return findNext(keyword, reset, true); return findNext(currentKeyword, reset, true);
}; };
export { next, prev }; export { next, prev };

@ -1,9 +1,10 @@
import actions from 'content/actions'; import actions from 'content/actions';
const enable = (newTab) => { const enable = (newTab, background) => {
return { return {
type: actions.FOLLOW_CONTROLLER_ENABLE, type: actions.FOLLOW_CONTROLLER_ENABLE,
newTab, newTab,
background,
}; };
}; };

@ -2,12 +2,16 @@ import operations from 'shared/operations';
import messages from 'shared/messages'; import messages from 'shared/messages';
import * as scrolls from 'content/scrolls'; import * as scrolls from 'content/scrolls';
import * as navigates from 'content/navigates'; import * as navigates from 'content/navigates';
import * as focuses from 'content/focuses';
import * as urls from 'content/urls'; import * as urls from 'content/urls';
import * as consoleFrames from 'content/console-frames'; import * as consoleFrames from 'content/console-frames';
import * as addonActions from './addon'; import * as addonActions from './addon';
import * as properties from 'shared/settings/properties';
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
const exec = (operation) => { const exec = (operation, repeat, settings) => {
let smoothscroll = settings.properties.smoothscroll ||
properties.defaults.smoothscroll;
switch (operation.type) { switch (operation.type) {
case operations.ADDON_ENABLE: case operations.ADDON_ENABLE:
return addonActions.enable(); return addonActions.enable();
@ -24,23 +28,24 @@ const exec = (operation) => {
type: messages.FIND_PREV, type: messages.FIND_PREV,
}), '*'); }), '*');
case operations.SCROLL_VERTICALLY: case operations.SCROLL_VERTICALLY:
return scrolls.scrollVertically(window, operation.count); return scrolls.scrollVertically(operation.count, smoothscroll, repeat);
case operations.SCROLL_HORIZONALLY: case operations.SCROLL_HORIZONALLY:
return scrolls.scrollHorizonally(window, operation.count); return scrolls.scrollHorizonally(operation.count, smoothscroll, repeat);
case operations.SCROLL_PAGES: case operations.SCROLL_PAGES:
return scrolls.scrollPages(window, operation.count); return scrolls.scrollPages(operation.count, smoothscroll, repeat);
case operations.SCROLL_TOP: case operations.SCROLL_TOP:
return scrolls.scrollTop(window); return scrolls.scrollTop(smoothscroll, repeat);
case operations.SCROLL_BOTTOM: case operations.SCROLL_BOTTOM:
return scrolls.scrollBottom(window); return scrolls.scrollBottom(smoothscroll, repeat);
case operations.SCROLL_HOME: case operations.SCROLL_HOME:
return scrolls.scrollHome(window); return scrolls.scrollHome(smoothscroll, repeat);
case operations.SCROLL_END: case operations.SCROLL_END:
return scrolls.scrollEnd(window); return scrolls.scrollEnd(smoothscroll, repeat);
case operations.FOLLOW_START: case operations.FOLLOW_START:
return window.top.postMessage(JSON.stringify({ return window.top.postMessage(JSON.stringify({
type: messages.FOLLOW_START, type: messages.FOLLOW_START,
newTab: operation.newTab newTab: operation.newTab,
background: operation.background,
}), '*'); }), '*');
case operations.NAVIGATE_HISTORY_PREV: case operations.NAVIGATE_HISTORY_PREV:
return navigates.historyPrev(window); return navigates.historyPrev(window);
@ -54,12 +59,13 @@ const exec = (operation) => {
return navigates.parent(window); return navigates.parent(window);
case operations.NAVIGATE_ROOT: case operations.NAVIGATE_ROOT:
return navigates.root(window); return navigates.root(window);
case operations.FOCUS_INPUT:
return focuses.focusInput();
case operations.URLS_YANK: case operations.URLS_YANK:
urls.yank(window); urls.yank(window);
return consoleFrames.postMessage(window.document, { return consoleFrames.postInfo(window.document, 'Current url yanked');
type: messages.CONSOLE_SHOW_INFO, case operations.URLS_PASTE:
text: 'Current url yanked', return urls.paste(window, operation.newTab ? operation.newTab : false);
});
default: default:
browser.runtime.sendMessage({ browser.runtime.sendMessage({
type: messages.BACKGROUND_OPERATION, type: messages.BACKGROUND_OPERATION,

@ -1,10 +1,17 @@
import actions from 'content/actions'; import actions from 'content/actions';
import * as keyUtils from 'shared/utils/keys'; import * as keyUtils from 'shared/utils/keys';
import operations from 'shared/operations';
const reservedKeymaps = {
'<Esc>': { type: operations.CANCEL },
'<C-[>': { type: operations.CANCEL },
};
const set = (value) => { const set = (value) => {
let entries = []; let entries = [];
if (value.keymaps) { if (value.keymaps) {
entries = Object.entries(value.keymaps).map((entry) => { let keymaps = Object.assign({}, value.keymaps, reservedKeymaps);
entries = Object.entries(keymaps).map((entry) => {
return [ return [
keyUtils.fromMapKeys(entry[0]), keyUtils.fromMapKeys(entry[0]),
entry[1], entry[1],

@ -4,7 +4,8 @@ import * as dom from 'shared/utils/dom';
const TARGET_SELECTOR = [ const TARGET_SELECTOR = [
'a', 'button', 'input', 'textarea', 'area', 'a', 'button', 'input', 'textarea', 'area',
'[contenteditable=true]', '[contenteditable=""]', '[tabindex]' '[contenteditable=true]', '[contenteditable=""]', '[tabindex]',
'[role="button"]'
].join(','); ].join(',');
@ -29,11 +30,27 @@ const inViewport = (win, element, viewSize, framePosition) => {
return true; return true;
}; };
const isAriaHiddenOrAriaDisabled = (win, element) => {
if (!element || win.document.documentElement === element) {
return false;
}
for (let attr of ['aria-hidden', 'aria-disabled']) {
if (element.hasAttribute(attr)) {
let hidden = element.getAttribute(attr).toLowerCase();
if (hidden === '' || hidden === 'true') {
return true;
}
}
}
return isAriaHiddenOrAriaDisabled(win, element.parentNode);
};
export default class Follow { export default class Follow {
constructor(win, store) { constructor(win, store) {
this.win = win; this.win = win;
this.store = store; this.store = store;
this.newTab = false; this.newTab = false;
this.background = false;
this.hints = {}; this.hints = {};
this.targets = []; this.targets = [];
@ -47,6 +64,7 @@ export default class Follow {
this.win.parent.postMessage(JSON.stringify({ this.win.parent.postMessage(JSON.stringify({
type: messages.FOLLOW_KEY_PRESS, type: messages.FOLLOW_KEY_PRESS,
key: key.key, key: key.key,
ctrlKey: key.ctrlKey,
}), '*'); }), '*');
return true; return true;
} }
@ -68,6 +86,7 @@ export default class Follow {
type: messages.OPEN_URL, type: messages.OPEN_URL,
url: element.href, url: element.href,
newTab: true, newTab: true,
background: this.background,
}); });
} }
@ -79,12 +98,13 @@ export default class Follow {
}), '*'); }), '*');
} }
createHints(keysArray, newTab) { createHints(keysArray, newTab, background) {
if (keysArray.length !== this.targets.length) { if (keysArray.length !== this.targets.length) {
throw new Error('illegal hint count'); throw new Error('illegal hint count');
} }
this.newTab = newTab; this.newTab = newTab;
this.background = background;
this.hints = {}; this.hints = {};
for (let i = 0; i < keysArray.length; ++i) { for (let i = 0; i < keysArray.length; ++i) {
let keys = keysArray[i]; let keys = keysArray[i];
@ -150,7 +170,8 @@ export default class Follow {
case messages.FOLLOW_REQUEST_COUNT_TARGETS: case messages.FOLLOW_REQUEST_COUNT_TARGETS:
return this.countHints(sender, message.viewSize, message.framePosition); return this.countHints(sender, message.viewSize, message.framePosition);
case messages.FOLLOW_CREATE_HINTS: case messages.FOLLOW_CREATE_HINTS:
return this.createHints(message.keysArray, message.newTab); return this.createHints(
message.keysArray, message.newTab, message.background);
case messages.FOLLOW_SHOW_HINTS: case messages.FOLLOW_SHOW_HINTS:
return this.showHints(message.keys); return this.showHints(message.keys);
case messages.FOLLOW_ACTIVATE: case messages.FOLLOW_ACTIVATE:
@ -170,6 +191,7 @@ export default class Follow {
style.visibility !== 'hidden' && style.visibility !== 'hidden' &&
element.type !== 'hidden' && element.type !== 'hidden' &&
element.offsetHeight > 0 && element.offsetHeight > 0 &&
!isAriaHiddenOrAriaDisabled(win, element) &&
inViewport(win, element, viewSize, framePosition); inViewport(win, element, viewSize, framePosition);
}); });
return filtered; return filtered;

@ -1,6 +1,10 @@
import * as dom from 'shared/utils/dom'; import * as dom from 'shared/utils/dom';
import * as keys from 'shared/utils/keys'; import * as keys from 'shared/utils/keys';
const cancelKey = (e) => {
return e.key === 'Escape' || e.key === '[' && e.ctrlKey;
};
export default class InputComponent { export default class InputComponent {
constructor(target) { constructor(target) {
this.pressed = {}; this.pressed = {};
@ -37,7 +41,7 @@ export default class InputComponent {
capture(e) { capture(e) {
if (this.fromInput(e)) { if (this.fromInput(e)) {
if (e.key === 'Escape' && e.target.blur) { if (cancelKey(e) && e.target.blur) {
e.target.blur(); e.target.blur();
} }
return; return;

@ -47,7 +47,8 @@ export default class KeymapperComponent {
return true; return true;
} }
let operation = keymaps.get(matched[0]); let operation = keymaps.get(matched[0]);
this.store.dispatch(operationActions.exec(operation)); this.store.dispatch(operationActions.exec(
operation, key.repeat, state.setting));
this.store.dispatch(inputActions.clearKeys()); this.store.dispatch(inputActions.clearKeys());
return true; return true;
} }

@ -1,6 +1,5 @@
import * as findActions from 'content/actions/find'; import * as findActions from 'content/actions/find';
import messages from 'shared/messages'; import messages from 'shared/messages';
import * as consoleFrames from '../../console-frames';
export default class FindComponent { export default class FindComponent {
constructor(win, store) { constructor(win, store) {
@ -32,23 +31,11 @@ export default class FindComponent {
next() { next() {
let state = this.store.getState().find; let state = this.store.getState().find;
if (!state.found) {
return consoleFrames.postError(
window.document,
'Pattern not found: ' + state.keyword);
}
return this.store.dispatch(findActions.next(state.keyword, false)); return this.store.dispatch(findActions.next(state.keyword, false));
} }
prev() { prev() {
let state = this.store.getState().find; let state = this.store.getState().find;
if (!state.found) {
return consoleFrames.postError(
window.document,
'Pattern not found: ' + state.keyword);
}
return this.store.dispatch(findActions.prev(state.keyword, false)); return this.store.dispatch(findActions.prev(state.keyword, false));
} }
} }

@ -1,8 +1,7 @@
import * as followControllerActions from 'content/actions/follow-controller'; import * as followControllerActions from 'content/actions/follow-controller';
import messages from 'shared/messages'; import messages from 'shared/messages';
import HintKeyProducer from 'content/hint-key-producer'; import HintKeyProducer from 'content/hint-key-producer';
import * as properties from 'shared/settings/properties';
const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz';
const broadcastMessage = (win, message) => { const broadcastMessage = (win, message) => {
let json = JSON.stringify(message); let json = JSON.stringify(message);
@ -29,11 +28,11 @@ export default class FollowController {
switch (message.type) { switch (message.type) {
case messages.FOLLOW_START: case messages.FOLLOW_START:
return this.store.dispatch( return this.store.dispatch(
followControllerActions.enable(message.newTab)); followControllerActions.enable(message.newTab, message.background));
case messages.FOLLOW_RESPONSE_COUNT_TARGETS: case messages.FOLLOW_RESPONSE_COUNT_TARGETS:
return this.create(message.count, sender); return this.create(message.count, sender);
case messages.FOLLOW_KEY_PRESS: case messages.FOLLOW_KEY_PRESS:
return this.keyPress(message.key); return this.keyPress(message.key, message.ctrlKey);
} }
} }
@ -70,7 +69,11 @@ export default class FollowController {
}); });
} }
keyPress(key) { keyPress(key, ctrlKey) {
if (key === '[' && ctrlKey) {
this.store.dispatch(followControllerActions.disable());
return true;
}
switch (key) { switch (key) {
case 'Enter': case 'Enter':
this.activate(); this.activate();
@ -84,7 +87,7 @@ export default class FollowController {
this.store.dispatch(followControllerActions.backspace()); this.store.dispatch(followControllerActions.backspace());
break; break;
default: default:
if (DEFAULT_HINT_CHARSET.includes(key)) { if (this.hintchars().includes(key)) {
this.store.dispatch(followControllerActions.keyPress(key)); this.store.dispatch(followControllerActions.keyPress(key));
} }
break; break;
@ -93,7 +96,7 @@ export default class FollowController {
} }
count() { count() {
this.producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); this.producer = new HintKeyProducer(this.hintchars());
let doc = this.win.document; let doc = this.win.document;
let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth; let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth;
let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight; let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight;
@ -126,6 +129,7 @@ export default class FollowController {
type: messages.FOLLOW_CREATE_HINTS, type: messages.FOLLOW_CREATE_HINTS,
keysArray: produced, keysArray: produced,
newTab: this.state.newTab, newTab: this.state.newTab,
background: this.state.background,
}), '*'); }), '*');
} }
@ -135,4 +139,9 @@ export default class FollowController {
type: messages.FOLLOW_REMOVE_HINTS, type: messages.FOLLOW_REMOVE_HINTS,
}); });
} }
hintchars() {
return this.store.getState().setting.properties.hintchars ||
properties.defaults.hintchars;
}
} }

@ -28,4 +28,11 @@ const postError = (doc, message) => {
}); });
}; };
export { initialize, blur, postMessage, postError }; const postInfo = (doc, message) => {
return postMessage(doc, {
type: messages.CONSOLE_SHOW_INFO,
text: message,
});
};
export { initialize, blur, postError, postInfo };

@ -0,0 +1,13 @@
import * as doms from 'shared/utils/dom';
const focusInput = () => {
let inputTypes = ['email', 'number', 'search', 'tel', 'text', 'url'];
let inputSelector = inputTypes.map(type => `input[type=${type}]`).join(',');
let targets = window.document.querySelectorAll(inputSelector + ',textarea');
let target = Array.from(targets).find(doms.isVisible);
if (target) {
target.focus();
}
};
export { focusInput };

@ -1,17 +1,18 @@
const PREV_LINK_PATTERNS = [ const REL_PATTERN = {
/\bprev\b/i, /\bprevious\b/i, /\bback\b/i, prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i,
/</, /\u2039/, /\u2190/, /\xab/, /\u226a/, /<</ next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i,
]; };
const NEXT_LINK_PATTERNS = [
/\bnext\b/i, // Return the last element in the document matching the supplied selector
/>/, /\u203a/, /\u2192/, /\xbb/, /\u226b/, />>/ // and the optional filter, or null if there are no matches.
]; const selectLast = (win, selector, filter) => {
let nodes = win.document.querySelectorAll(selector);
const findLinkByPatterns = (win, patterns) => {
let links = win.document.getElementsByTagName('a'); if (filter) {
return Array.prototype.find.call(links, (link) => { nodes = Array.from(nodes).filter(filter);
return patterns.some(ptn => ptn.test(link.textContent)); }
});
return nodes.length ? nodes[nodes.length - 1] : null;
}; };
const historyPrev = (win) => { const historyPrev = (win) => {
@ -22,30 +23,37 @@ const historyNext = (win) => {
win.history.forward(); win.history.forward();
}; };
const linkPrev = (win) => { // Code common to linkPrev and linkNext which navigates to the specified page.
let link = win.document.querySelector('a[rel=prev]'); const linkRel = (win, rel) => {
let link = selectLast(win, `link[rel~=${rel}][href]`);
if (link) { if (link) {
return link.click(); win.location = link.href;
return;
} }
link = findLinkByPatterns(win, PREV_LINK_PATTERNS);
const pattern = REL_PATTERN[rel];
link = selectLast(win, `a[rel~=${rel}][href]`) ||
// `innerText` is much slower than `textContent`, but produces much better
// (i.e. less unexpected) results
selectLast(win, 'a[href]', lnk => pattern.test(lnk.innerText));
if (link) { if (link) {
link.click(); link.click();
} }
}; };
const linkPrev = (win) => {
linkRel(win, 'prev');
};
const linkNext = (win) => { const linkNext = (win) => {
let link = win.document.querySelector('a[rel=next]'); linkRel(win, 'next');
if (link) {
return link.click();
}
link = findLinkByPatterns(win, NEXT_LINK_PATTERNS);
if (link) {
link.click();
}
}; };
const parent = (win) => { const parent = (win) => {
let loc = win.location; const loc = win.location;
if (loc.hash !== '') { if (loc.hash !== '') {
loc.hash = ''; loc.hash = '';
return; return;

@ -1,7 +1,7 @@
import actions from 'content/actions'; import actions from 'content/actions';
const defaultState = { const defaultState = {
keyword: '', keyword: null,
found: false, found: false,
}; };

@ -3,6 +3,7 @@ import actions from 'content/actions';
const defaultState = { const defaultState = {
enabled: false, enabled: false,
newTab: false, newTab: false,
background: false,
keys: '', keys: '',
}; };
@ -12,6 +13,7 @@ export default function reducer(state = defaultState, action = {}) {
return Object.assign({}, state, { return Object.assign({}, state, {
enabled: true, enabled: true,
newTab: action.newTab, newTab: action.newTab,
background: action.background,
keys: '', keys: '',
}); });
case actions.FOLLOW_CONTROLLER_DISABLE: case actions.FOLLOW_CONTROLLER_DISABLE:

@ -1,27 +1,14 @@
import * as doms from 'shared/utils/dom';
const SCROLL_DELTA_X = 48; const SCROLL_DELTA_X = 48;
const SCROLL_DELTA_Y = 48; const SCROLL_DELTA_Y = 48;
const SMOOTH_SCROLL_DURATION = 150;
const isVisible = (win, element) => { // dirty way to store scrolling state on globally
let rect = element.getBoundingClientRect(); let scrolling = [false];
if (rect.width === 0 || rect.height === 0) {
return false;
}
if (rect.right < 0 && rect.bottom < 0) {
return false;
}
if (win.innerWidth < rect.left && win.innerHeight < rect.top) {
return false;
}
let { display, visibility } = win.getComputedStyle(element);
if (display === 'none' || visibility === 'hidden') {
return false;
}
return true;
};
const isScrollableStyle = (win, element) => { const isScrollableStyle = (element) => {
let { overflowX, overflowY } = win.getComputedStyle(element); let { overflowX, overflowY } = window.getComputedStyle(element);
return !(overflowX !== 'scroll' && overflowX !== 'auto' && return !(overflowX !== 'scroll' && overflowX !== 'auto' &&
overflowY !== 'scroll' && overflowY !== 'auto'); overflowY !== 'scroll' && overflowY !== 'auto');
}; };
@ -35,15 +22,14 @@ const isOverflowed = (element) => {
// this method is called by each scrolling, and the returned value of this // this method is called by each scrolling, and the returned value of this
// method is not cached. That does not cause performance issue because in the // method is not cached. That does not cause performance issue because in the
// most pages, the window is root element i,e, documentElement. // most pages, the window is root element i,e, documentElement.
const findScrollable = (win, element) => { const findScrollable = (element) => {
if (isScrollableStyle(win, element) && isOverflowed(element)) { if (isScrollableStyle(element) && isOverflowed(element)) {
return element; return element;
} }
let children = Array.prototype let children = Array.from(element.children).filter(doms.isVisible);
.filter.call(element.children, e => isVisible(win, e));
for (let child of children) { for (let child of children) {
let scrollable = findScrollable(win, child); let scrollable = findScrollable(child);
if (scrollable) { if (scrollable) {
return scrollable; return scrollable;
} }
@ -51,68 +37,153 @@ const findScrollable = (win, element) => {
return null; return null;
}; };
const scrollTarget = (win) => { const scrollTarget = () => {
if (isOverflowed(win.document.documentElement)) { if (isOverflowed(window.document.documentElement)) {
return win.document.documentElement; return window.document.documentElement;
} }
if (isOverflowed(win.document.body)) { if (isOverflowed(window.document.body)) {
return win.document.body; return window.document.body;
} }
let target = findScrollable(win, win.document.documentElement); let target = findScrollable(window.document.documentElement);
if (target) { if (target) {
return target; return target;
} }
return win.document.documentElement; return window.document.documentElement;
}; };
const scrollVertically = (win, count) => { class SmoothScroller {
let target = scrollTarget(win); constructor(element, repeat) {
this.element = element;
this.repeat = repeat;
this.scrolling = scrolling;
if (repeat) {
this.easing = SmoothScroller.linearEasing;
} else {
this.easing = SmoothScroller.inOutQuadEasing;
}
}
scroll(x, y) {
if (this.scrolling[0]) {
return;
}
scrolling[0] = true;
this.startX = this.element.scrollLeft;
this.startY = this.element.scrollTop;
this.targetX = x;
this.targetY = y;
this.distanceX = x - this.startX;
this.distanceY = y - this.startY;
this.timeStart = 0;
window.requestAnimationFrame(this.loop.bind(this));
}
loop(time) {
if (!this.timeStart) {
this.timeStart = time;
}
let elapsed = time - this.timeStart;
let v = this.easing(elapsed / SMOOTH_SCROLL_DURATION);
let nextX = this.startX + this.distanceX * v;
let nextY = this.startY + this.distanceY * v;
window.scrollTo(nextX, nextY);
if (elapsed < SMOOTH_SCROLL_DURATION) {
window.requestAnimationFrame(this.loop.bind(this));
} else {
scrolling[0] = false;
this.element.scrollTo(this.targetX, this.targetY);
}
}
static inOutQuadEasing(t) {
if (t < 1) {
return t * t;
}
return -(t - 1) * (t - 1) + 1;
}
static linearEasing(t) {
return t;
}
}
class RoughtScroller {
constructor(element) {
this.element = element;
}
scroll(x, y) {
this.element.scrollTo(x, y);
}
}
const scroller = (element, smooth, repeat) => {
if (smooth) {
return new SmoothScroller(element, repeat);
}
return new RoughtScroller(element);
};
const scrollVertically = (count, smooth, repeat) => {
let target = scrollTarget();
let x = target.scrollLeft; let x = target.scrollLeft;
let y = target.scrollTop + SCROLL_DELTA_Y * count; let y = target.scrollTop + SCROLL_DELTA_Y * count;
target.scrollTo(x, y); if (repeat && smooth) {
y = target.scrollTop + SCROLL_DELTA_Y * count * 4;
}
scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollHorizonally = (win, count) => { const scrollHorizonally = (count, smooth, repeat) => {
let target = scrollTarget(win); let target = scrollTarget();
let x = target.scrollLeft + SCROLL_DELTA_X * count; let x = target.scrollLeft + SCROLL_DELTA_X * count;
let y = target.scrollTop; let y = target.scrollTop;
target.scrollTo(x, y); if (repeat && smooth) {
y = target.scrollTop + SCROLL_DELTA_Y * count * 4;
}
scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollPages = (win, count) => { const scrollPages = (count, smooth, repeat) => {
let target = scrollTarget(win); let target = scrollTarget();
let height = target.clientHeight; let height = target.clientHeight;
let x = target.scrollLeft; let x = target.scrollLeft;
let y = target.scrollTop + height * count; let y = target.scrollTop + height * count;
target.scrollTo(x, y); scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollTop = (win) => { const scrollTop = (smooth, repeat) => {
let target = scrollTarget(win); let target = scrollTarget();
let x = target.scrollLeft; let x = target.scrollLeft;
let y = 0; let y = 0;
target.scrollTo(x, y); scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollBottom = (win) => { const scrollBottom = (smooth, repeat) => {
let target = scrollTarget(win); let target = scrollTarget();
let x = target.scrollLeft; let x = target.scrollLeft;
let y = target.scrollHeight; let y = target.scrollHeight;
target.scrollTo(x, y); scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollHome = (win) => { const scrollHome = (smooth, repeat) => {
let target = scrollTarget(win); let target = scrollTarget();
let x = 0; let x = 0;
let y = target.scrollTop; let y = target.scrollTop;
target.scrollTo(x, y); scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollEnd = (win) => { const scrollEnd = (smooth, repeat) => {
let target = scrollTarget(win); let target = scrollTarget();
let x = target.scrollWidth; let x = target.scrollWidth;
let y = target.scrollTop; let y = target.scrollTop;
target.scrollTo(x, y); scroller(target, smooth, repeat).scroll(x, y);
}; };
export { export {

@ -1,3 +1,5 @@
import messages from 'shared/messages';
const yank = (win) => { const yank = (win) => {
let input = win.document.createElement('input'); let input = win.document.createElement('input');
win.document.body.append(input); win.document.body.append(input);
@ -12,4 +14,26 @@ const yank = (win) => {
input.remove(); input.remove();
}; };
export { yank }; const paste = (win, newTab) => {
let textarea = win.document.createElement('textarea');
win.document.body.append(textarea);
textarea.style.position = 'fixed';
textarea.style.top = '-100px';
textarea.contentEditable = 'true';
textarea.focus();
if (win.document.execCommand('paste')) {
if (/^(https?|ftp):\/\//.test(textarea.textContent)) {
browser.runtime.sendMessage({
type: messages.OPEN_URL,
url: textarea.textContent,
newTab: newTab ? newTab : false,
});
}
}
textarea.remove();
};
export { yank, paste };

@ -1,34 +1,39 @@
import actions from 'settings/actions'; import actions from 'settings/actions';
import messages from 'shared/messages'; import messages from 'shared/messages';
import DefaultSettings from 'shared/default-settings'; import DefaultSettings from 'shared/settings/default';
import * as settingsStorage from 'shared/settings/storage';
import * as settingsValues from 'shared/settings/values';
const load = () => { const load = () => {
return browser.storage.local.get('settings').then(({ settings }) => { return settingsStorage.loadRaw().then((settings) => {
if (settings) { return set(settings);
return set(settings); });
}
return set(DefaultSettings);
}, console.error);
}; };
const save = (settings) => { const save = (settings) => {
return browser.storage.local.set({ return settingsStorage.save(settings).then(() => {
settings,
}).then(() => {
return browser.runtime.sendMessage({ return browser.runtime.sendMessage({
type: messages.SETTINGS_RELOAD type: messages.SETTINGS_RELOAD
}).then(() => {
return set(settings);
}); });
}).then(() => {
return set(settings);
}); });
}; };
const set = (settings) => { const set = (settings) => {
let value = JSON.parse(DefaultSettings.json);
if (settings.source === 'json') {
value = settingsValues.valueFromJson(settings.json);
} else if (settings.source === 'form') {
value = settingsValues.valueFromForm(settings.form);
}
return { return {
type: actions.SETTING_SET_SETTINGS, type: actions.SETTING_SET_SETTINGS,
source: settings.source, source: settings.source,
json: settings.json, json: settings.json,
value: JSON.parse(settings.json), form: settings.form,
value,
}; };
}; };

@ -0,0 +1,52 @@
import './blacklist-form.scss';
import AddButton from '../ui/add-button';
import DeleteButton from '../ui/delete-button';
import { h, Component } from 'preact';
class BlacklistForm extends Component {
render() {
let value = this.props.value;
if (!value) {
value = [];
}
return <div className='form-blacklist-form'>
{
value.map((url, index) => {
return <div key={index} className='form-blacklist-form-row'>
<input data-index={index} type='text' name='url'
className='column-url' value={url}
onChange={this.bindValue.bind(this)} />
<DeleteButton data-index={index} name='delete'
onClick={this.bindValue.bind(this)} />
</div>;
})
}
<AddButton name='add' style='float:right'
onClick={this.bindValue.bind(this)} />
</div>;
}
bindValue(e) {
if (!this.props.onChange) {
return;
}
let name = e.target.name;
let index = e.target.getAttribute('data-index');
let next = this.props.value ? this.props.value.slice() : [];
if (name === 'url') {
next[index] = e.target.value;
} else if (name === 'add') {
next.push('');
} else if (name === 'delete') {
next.splice(index, 1);
}
this.props.onChange(next);
}
}
export default BlacklistForm;

@ -0,0 +1,9 @@
.form-blacklist-form {
&-row {
display: flex;
.column-url {
flex: 1;
}
}
}

@ -0,0 +1,109 @@
import './keymaps-form.scss';
import { h, Component } from 'preact';
import Input from '../ui/input';
const KeyMapFields = [
[
['scroll.vertically?{"count":1}', 'Scroll down'],
['scroll.vertically?{"count":-1}', 'Scroll up'],
['scroll.horizonally?{"count":-1}', 'Scroll left'],
['scroll.horizonally?{"count":1}', 'Scroll right'],
['scroll.home', 'Scroll to leftmost'],
['scroll.end', 'Scroll to rightmost'],
['scroll.top', 'Scroll to top'],
['scroll.bottom', 'Scroll to bottom'],
['scroll.pages?{"count":-0.5}', 'Scroll up by half of screen'],
['scroll.pages?{"count":0.5}', 'Scroll down by half of screen'],
['scroll.pages?{"count":-1}', 'Scroll up by a screen'],
['scroll.pages?{"count":1}', 'Scroll down by a screen'],
], [
['tabs.close', 'Close a tab'],
['tabs.reopen', 'Reopen closed tab'],
['tabs.next?{"count":1}', 'Select next Tab'],
['tabs.prev?{"count":1}', 'Select prev Tab'],
['tabs.first', 'Select first tab'],
['tabs.last', 'Select last tab'],
['tabs.reload?{"cache":false}', 'Reload current tab'],
['tabs.reload?{"cache":true}', 'Reload with no caches'],
['tabs.pin.toggle', 'Toggle pinned state'],
['tabs.duplicate', 'Duplicate a tab'],
], [
['follow.start?{"newTab":false}', 'Follow a link'],
['follow.start?{"newTab":true}', 'Follow a link in new tab'],
['navigate.history.prev', 'Go back in histories'],
['navigate.history.next', 'Go forward in histories'],
['navigate.link.next', 'Open next link'],
['navigate.link.prev', 'Open previous link'],
['navigate.parent', 'Go to parent directory'],
['navigate.root', 'Go to root directory'],
['focus.input', 'Focus input'],
], [
['find.start', 'Start find mode'],
['find.next', 'Find next word'],
['find.prev', 'Find previous word'],
], [
['command.show', 'Open console'],
['command.show.open?{"alter":false}', 'Open URL'],
['command.show.open?{"alter":true}', 'Alter URL'],
['command.show.tabopen?{"alter":false}', 'Open URL in new Tab'],
['command.show.tabopen?{"alter":true}', 'Alter URL in new Tab'],
['command.show.winopen?{"alter":false}', 'Open URL in new window'],
['command.show.winopen?{"alter":true}', 'Alter URL in new window'],
['command.show.buffer', 'Open buffer command'],
], [
['addon.toggle.enabled', 'Enable or disable'],
['urls.yank', 'Copy current URL'],
['urls.paste?{"newTab":false}', 'Open clipboard\'s URL in current tab'],
['urls.paste?{"newTab":true}', 'Open clipboard\'s URL in new tab'],
['zoom.in', 'Zoom-in'],
['zoom.out', 'Zoom-out'],
['zoom.neutral', 'Reset zoom level'],
]
];
const AllowdOps = [].concat(...KeyMapFields.map(group => group.map(e => e[0])));
class KeymapsForm extends Component {
render() {
let values = this.props.value;
if (!values) {
values = {};
}
return <div className='form-keymaps-form'>
{
KeyMapFields.map((group, index) => {
return <div key={index} className='form-keymaps-form-field-group'>
{
group.map((field) => {
let name = field[0];
let label = field[1];
let value = values[name];
return <Input
type='text' id={name} name={name} key={name}
label={label} value={value}
onChange={this.bindValue.bind(this)}
/>;
})
}
</div>;
})
}
</div>;
}
bindValue(e) {
if (!this.props.onChange) {
return;
}
let next = Object.assign({}, this.props.value);
next[e.target.name] = e.target.value;
this.props.onChange(next);
}
}
KeymapsForm.AllowdOps = AllowdOps;
export default KeymapsForm;

@ -0,0 +1,11 @@
.form-keymaps-form {
column-count: 3;
&-field-group {
margin-top: 24px;
}
&-field-group:first-of-type {
margin-top: 24px;
}
}

@ -0,0 +1,60 @@
import './properties-form.scss';
import { h, Component } from 'preact';
class PropertiesForm extends Component {
render() {
let types = this.props.types;
let value = this.props.value;
if (!value) {
value = {};
}
return <div className='form-properties-form'>
{
Object.keys(types).map((name) => {
let type = types[name];
let inputType = null;
if (type === 'string') {
inputType = 'text';
} else if (type === 'number') {
inputType = 'number';
} else if (type === 'boolean') {
inputType = 'checkbox';
}
return <div key={name} className='form-properties-form-row'>
<label>
<span className='column-name'>{name}</span>
<input type={inputType} name={name}
className='column-input'
value={value[name] ? value[name] : ''}
onChange={this.bindValue.bind(this)}
checked={value[name]}
/>
</label>
</div>;
})
}
</div>;
}
bindValue(e) {
if (!this.props.onChange) {
return;
}
let name = e.target.name;
let next = Object.assign({}, this.props.value);
if (e.target.type.toLowerCase() === 'checkbox') {
next[name] = e.target.checked;
} else if (e.target.type.toLowerCase() === 'number') {
next[name] = Number(e.target.value);
} else {
next[name] = e.target.value;
}
this.props.onChange(next);
}
}
export default PropertiesForm;

@ -0,0 +1,12 @@
.form-properties-form {
&-row {
.column-name {
display: inline-block;
min-width: 5rem;
font-weight: bold;
}
.column-input {
line-height: 2.2rem;
}
}
}

@ -0,0 +1,78 @@
import './search-form.scss';
import { h, Component } from 'preact';
import AddButton from '../ui/add-button';
import DeleteButton from '../ui/delete-button';
class SearchForm extends Component {
render() {
let value = this.props.value;
if (!value) {
value = { default: '', engines: []};
}
if (!value.engines) {
value.engines = [];
}
return <div className='form-search-form'>
<div className='form-search-form-header'>
<div className='column-name'>Name</div>
<div className='column-url'>URL</div>
<div className='column-option'>Default</div>
</div>
{
value.engines.map((engine, index) => {
return <div key={index} className='form-search-form-row'>
<input data-index={index} type='text' name='name'
className='column-name' value={engine[0]}
onChange={this.bindValue.bind(this)} />
<input data-index={index} type='text' name='url'
placeholder='http://example.com/?q={}'
className='column-url' value={engine[1]}
onChange={this.bindValue.bind(this)} />
<div className='column-option'>
<input data-index={index} type='radio' name='default'
checked={value.default === engine[0]}
onChange={this.bindValue.bind(this)} />
<DeleteButton data-index={index} name='delete'
onClick={this.bindValue.bind(this)} />
</div>
</div>;
})
}
<AddButton name='add' style='float:right'
onClick={this.bindValue.bind(this)} />
</div>;
}
bindValue(e) {
if (!this.props.onChange) {
return;
}
let value = this.props.value;
let name = e.target.name;
let index = e.target.getAttribute('data-index');
let next = Object.assign({}, {
default: value.default,
engines: value.engines ? value.engines.slice() : [],
});
if (name === 'name') {
next.engines[index][0] = e.target.value;
next.default = this.props.value.engines[index][0];
} else if (name === 'url') {
next.engines[index][1] = e.target.value;
} else if (name === 'default') {
next.default = this.props.value.engines[index][0];
} else if (name === 'add') {
next.engines.push(['', '']);
} else if (name === 'delete') {
next.engines.splice(index, 1);
}
this.props.onChange(next);
}
}
export default SearchForm;

@ -0,0 +1,28 @@
.form-search-form {
@mixin row-base {
display: flex;
.column-name {
flex: 1;
min-width: 0;
}
.column-url {
flex: 5;
min-width: 0;
}
.column-option {
text-align: right;
flex-basis: 5rem;
}
}
&-header {
@include row-base;
font-weight: bold;
}
&-row {
@include row-base;
}
}

@ -1,16 +1,29 @@
import './site.scss'; import './site.scss';
import React from 'react'; import { h, Component } from 'preact';
import PropTypes from 'prop-types'; import Input from './ui/input';
import SearchForm from './form/search-form';
import KeymapsForm from './form/keymaps-form';
import BlacklistForm from './form/blacklist-form';
import PropertiesForm from './form/properties-form';
import * as properties from 'shared/settings/properties';
import * as settingActions from 'settings/actions/setting'; import * as settingActions from 'settings/actions/setting';
import * as validator from 'shared/validators/setting'; import * as validator from 'shared/settings/validator';
import * as settingsValues from 'shared/settings/values';
class SettingsComponent extends React.Component { const DO_YOU_WANT_TO_CONTINUE =
'Some settings in JSON can be lost when migrating. ' +
'Do you want to continue?';
class SettingsComponent extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
settings: { settings: {
json: '', json: '',
},
errors: {
json: '',
} }
}; };
this.context.store.subscribe(this.stateChanged.bind(this)); this.context.store.subscribe(this.stateChanged.bind(this));
@ -26,66 +39,187 @@ class SettingsComponent extends React.Component {
settings: { settings: {
source: settings.source, source: settings.source,
json: settings.json, json: settings.json,
form: settings.form,
} }
}); });
} }
renderFormFields() {
return <div>
<fieldset>
<legend>Keybindings</legend>
<KeymapsForm
value={this.state.settings.form.keymaps}
onChange={value => this.bindForm('keymaps', value)}
/>
</fieldset>
<fieldset>
<legend>Search Engines</legend>
<SearchForm
value={this.state.settings.form.search}
onChange={value => this.bindForm('search', value)}
/>
</fieldset>
<fieldset>
<legend>Blacklist</legend>
<BlacklistForm
value={this.state.settings.form.blacklist}
onChange={value => this.bindForm('blacklist', value)}
/>
</fieldset>
<fieldset>
<legend>Properties</legend>
<PropertiesForm
types={properties.types}
value={this.state.settings.form.properties}
onChange={value => this.bindForm('properties', value)}
/>
</fieldset>
</div>;
}
renderJsonFields() {
return <div>
<Input
type='textarea'
name='json'
label='Plane JSON'
spellCheck='false'
error={this.state.errors.json}
onChange={this.bindValue.bind(this)}
value={this.state.settings.json}
/>
</div>;
}
render() { render() {
let fields = null;
if (this.state.settings.source === 'form') {
fields = this.renderFormFields();
} else if (this.state.settings.source === 'json') {
fields = this.renderJsonFields();
}
return ( return (
<div> <div>
<h1>Configure Vim-Vixen</h1> <h1>Configure Vim-Vixen</h1>
<form className='vimvixen-settings-form'> <form className='vimvixen-settings-form'>
<Input
type='radio'
id='setting-source-form'
name='source'
label='Use form'
checked={this.state.settings.source === 'form'}
value='form'
onChange={this.bindSource.bind(this)} />
<p>Load settings from:</p> <Input
<input type='radio' id='setting-source-json' type='radio'
name='source' name='source'
label='Use plain JSON'
checked={this.state.settings.source === 'json'}
value='json' value='json'
onChange={this.bindAndSave.bind(this)} onChange={this.bindSource.bind(this)} />
checked={this.state.settings.source === 'json'} />
<label htmlFor='settings-source-json'>JSON</label> { fields }
<textarea name='json' spellCheck='false'
onInput={this.validate.bind(this)}
onChange={this.bindValue.bind(this)}
onBlur={this.bindAndSave.bind(this)}
value={this.state.settings.json} />
</form> </form>
</div> </div>
); );
} }
validate(e) { validate(target) {
try { if (target.name === 'json') {
let settings = JSON.parse(e.target.value); let settings = JSON.parse(target.value);
validator.validate(settings); validator.validate(settings);
e.target.setCustomValidity(''); }
}
validateValue(e) {
let next = Object.assign({}, this.state);
next.errors.json = '';
try {
this.validate(e.target);
} catch (err) { } catch (err) {
e.target.setCustomValidity(err.message); next.errors.json = err.message;
} }
next.settings[e.target.name] = e.target.value;
}
bindForm(name, value) {
let next = Object.assign({}, this.state, {
settings: Object.assign({}, this.state.settings, {
form: Object.assign({}, this.state.settings.form)
})
});
next.settings.form[name] = value;
this.setState(next);
this.context.store.dispatch(settingActions.save(next.settings));
} }
bindValue(e) { bindValue(e) {
let nextSettings = Object.assign({}, this.state.settings); let next = Object.assign({}, this.state);
nextSettings[e.target.name] = e.target.value; let error = false;
this.setState({ settings: nextSettings }); next.errors.json = '';
} try {
this.validate(e.target);
} catch (err) {
next.errors.json = err.message;
error = true;
}
next.settings[e.target.name] = e.target.value;
bindAndSave(e) { this.setState(this.state);
this.bindValue(e); if (!error) {
this.context.store.dispatch(settingActions.save(next.settings));
}
}
migrateToForm() {
let b = window.confirm(DO_YOU_WANT_TO_CONTINUE);
if (!b) {
this.setState(this.state);
return;
}
try { try {
let json = this.state.settings.json; validator.validate(JSON.parse(this.state.settings.json));
validator.validate(JSON.parse(json));
this.context.store.dispatch(settingActions.save(this.state.settings));
} catch (err) { } catch (err) {
// error already shown this.setState(this.state);
return;
} }
let form = settingsValues.formFromJson(
this.state.settings.json, KeymapsForm.AllowdOps);
let next = Object.assign({}, this.state);
next.settings.form = form;
next.settings.source = 'form';
next.errors.json = '';
this.setState(next);
this.context.store.dispatch(settingActions.save(next.settings));
}
migrateToJson() {
let json = settingsValues.jsonFromForm(this.state.settings.form);
let next = Object.assign({}, this.state);
next.settings.json = json;
next.settings.source = 'json';
next.errors.json = '';
this.setState(next);
this.context.store.dispatch(settingActions.save(next.settings));
} }
}
SettingsComponent.contextTypes = { bindSource(e) {
store: PropTypes.any, let from = this.state.settings.source;
}; let to = e.target.value;
if (from === 'form' && to === 'json') {
this.migrateToJson();
} else if (from === 'json' && to === 'form') {
this.migrateToForm();
}
}
}
export default SettingsComponent; export default SettingsComponent;

@ -1,8 +1,27 @@
.vimvixen-settings-form { .vimvixen-settings-form {
padding: 2px;
textarea[name=json] { textarea[name=json] {
font-family: monospace; font-family: monospace;
width: 100%; width: 100%;
min-height: 64ex; min-height: 64ex;
resize: vertical; resize: vertical;
} }
fieldset {
margin: 0;
padding: 0;
border: none;
margin-top: 1rem;
fieldset:first-of-type {
margin-top: 1rem;
}
legend {
font-size: 1.5rem;
padding: .5rem 0;
font-weight: bold;
}
}
} }

@ -0,0 +1,12 @@
import './add-button.scss';
import { h, Component } from 'preact';
class AddButton extends Component {
render() {
return <input
className='ui-add-button' type='button' value='&#x271a;'
{...this.props} />;
}
}
export default AddButton;

@ -0,0 +1,13 @@
.ui-add-button {
border: none;
padding: 4;
display: inline;
background: none;
font-weight: bold;
color: green;
cursor: pointer;
&:hover {
color: darkgreen;
}
}

@ -0,0 +1,12 @@
import './delete-button.scss';
import { h, Component } from 'preact';
class DeleteButton extends Component {
render() {
return <input
className='ui-delete-button' type='button' value='&#x2716;'
{...this.props} />;
}
}
export default DeleteButton;

@ -0,0 +1,13 @@
.ui-delete-button {
border: none;
padding: 4;
display: inline;
background: none;
color: red;
cursor: pointer;
&:hover {
color: darkred;
}
}

@ -0,0 +1,52 @@
import { h, Component } from 'preact';
import './input.scss';
class Input extends Component {
renderText(props) {
let inputClassName = props.error ? 'input-error' : '';
return <div className='settings-ui-input'>
<label htmlFor={props.id}>{ props.label }</label>
<input type='text' className={inputClassName} {...props} />
</div>;
}
renderRadio(props) {
let inputClassName = props.error ? 'input-error' : '';
return <div className='settings-ui-input'>
<label>
<input type='radio' className={inputClassName} {...props} />
{ props.label }
</label>
</div>;
}
renderTextArea(props) {
let inputClassName = props.error ? 'input-error' : '';
return <div className='settings-ui-input'>
<label
htmlFor={props.id}
>{ props.label }</label>
<textarea className={inputClassName} {...props} />
<p className='settings-ui-input-error'>{ this.props.error }</p>
</div>;
}
render() {
let { type } = this.props;
switch (this.props.type) {
case 'text':
return this.renderText(this.props);
case 'radio':
return this.renderRadio(this.props);
case 'textarea':
return this.renderTextArea(this.props);
default:
console.warn(`Unsupported input type ${type}`);
}
return null;
}
}
export default Input;

@ -0,0 +1,29 @@
.settings-ui-input {
page-break-inside: avoid;
* {
page-break-inside: avoid;
}
label {
font-weight: bold;
min-width: 14rem;
display: inline-block;
}
input[type='text'] {
padding: 4px;
width: 8rem;
}
input.input-crror,
textarea.input-error {
box-shadow: 0 0 2px red;
}
&-error {
font-weight: bold;
color: red;
min-height: 1.5em;
}
}

@ -1,5 +1,4 @@
import React from 'react'; import { h, render } from 'preact';
import ReactDOM from 'react-dom';
import SettingsComponent from './components'; import SettingsComponent from './components';
import reducer from 'settings/reducers/setting'; import reducer from 'settings/reducers/setting';
import Provider from 'shared/store/provider'; import Provider from 'shared/store/provider';
@ -9,7 +8,7 @@ const store = createStore(reducer);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
let wrapper = document.getElementById('vimvixen-settings'); let wrapper = document.getElementById('vimvixen-settings');
ReactDOM.render( render(
<Provider store={store}> <Provider store={store}>
<SettingsComponent /> <SettingsComponent />
</Provider>, </Provider>,

@ -3,6 +3,7 @@ import actions from 'settings/actions';
const defaultState = { const defaultState = {
source: '', source: '',
json: '', json: '',
form: null,
value: {} value: {}
}; };
@ -12,6 +13,7 @@ export default function reducer(state = defaultState, action = {}) {
return { return {
source: action.source, source: action.source,
json: action.json, json: action.json,
form: action.form,
value: action.value, value: action.value,
}; };
default: default:

@ -1,169 +0,0 @@
import * as tabs from 'background/tabs';
import * as histories from 'background/histories';
const normalizeUrl = (args, searchConfig) => {
let concat = args.join(' ');
try {
return new URL(concat).href;
} catch (e) {
if (concat.includes('.') && !concat.includes(' ')) {
return 'http://' + concat;
}
let query = encodeURI(concat);
let template = searchConfig.engines[
searchConfig.default
];
for (let key in searchConfig.engines) {
if (args[0] === key) {
query = args.slice(1).join(' ');
template = searchConfig.engines[key];
}
}
return template.replace('{}', query);
}
};
const openCommand = (url) => {
return browser.tabs.query({
active: true, currentWindow: true
}).then((gotTabs) => {
if (gotTabs.length > 0) {
return browser.tabs.update(gotTabs[0].id, { url: url });
}
});
};
const tabopenCommand = (url) => {
return browser.tabs.create({ url: url });
};
const winopenCommand = (url) => {
return browser.windows.create({ url });
};
const bufferCommand = (keywords) => {
if (keywords.length === 0) {
return Promise.resolve([]);
}
let keywordsStr = keywords.join(' ');
return browser.tabs.query({
active: true, currentWindow: true
}).then((gotTabs) => {
if (gotTabs.length > 0) {
if (isNaN(keywordsStr)) {
return tabs.selectByKeyword(gotTabs[0], keywordsStr);
}
let index = parseInt(keywordsStr, 10) - 1;
return tabs.selectAt(index);
}
});
};
const getOpenCompletions = (command, keywords, searchConfig) => {
return histories.getCompletions(keywords).then((pages) => {
let historyItems = pages.map((page) => {
return {
caption: page.title,
content: command + ' ' + page.url,
url: page.url
};
});
let engineNames = Object.keys(searchConfig.engines);
let engineItems = engineNames.filter(name => name.startsWith(keywords))
.map(name => ({
caption: name,
content: command + ' ' + name
}));
let completions = [];
if (engineItems.length > 0) {
completions.push({
name: 'Search Engines',
items: engineItems
});
}
if (historyItems.length > 0) {
completions.push({
name: 'History',
items: historyItems
});
}
return completions;
});
};
const doCommand = (line, settings) => {
let words = line.trim().split(/ +/);
let name = words.shift();
switch (name) {
case 'o':
case 'open':
return openCommand(normalizeUrl(words, settings.search));
case 't':
case 'tabopen':
return tabopenCommand(normalizeUrl(words, settings.search));
case 'w':
case 'winopen':
return winopenCommand(normalizeUrl(words, settings.search));
case 'b':
case 'buffer':
return bufferCommand(words);
case '':
return Promise.resolve();
}
throw new Error(name + ' command is not defined');
};
const getCompletions = (line, settings) => {
let typedWords = line.trim().split(/ +/);
let typing = '';
if (!line.endsWith(' ')) {
typing = typedWords.pop();
}
if (typedWords.length === 0) {
return Promise.resolve([]);
}
let name = typedWords.shift();
let keywords = typedWords.concat(typing).join(' ');
switch (name) {
case 'o':
case 'open':
case 't':
case 'tabopen':
case 'w':
case 'winopen':
return getOpenCompletions(name, keywords, settings.search);
case 'b':
case 'buffer':
return tabs.getCompletions(keywords).then((gotTabs) => {
let items = gotTabs.map((tab) => {
return {
caption: tab.title,
content: name + ' ' + tab.title,
url: tab.url,
icon: tab.favIconUrl
};
});
return [
{
name: 'Buffers',
items: items
}
];
});
}
return Promise.resolve([]);
};
const exec = (line, settings) => {
return doCommand(line, settings);
};
const complete = (line, settings) => {
return getCompletions(line, settings);
};
export { exec, complete };

@ -0,0 +1,84 @@
import * as tabs from 'background/tabs';
import * as histories from 'background/histories';
const getOpenCompletions = (command, keywords, searchConfig) => {
return histories.getCompletions(keywords).then((pages) => {
let historyItems = pages.map((page) => {
return {
caption: page.title,
content: command + ' ' + page.url,
url: page.url
};
});
let engineNames = Object.keys(searchConfig.engines);
let engineItems = engineNames.filter(name => name.startsWith(keywords))
.map(name => ({
caption: name,
content: command + ' ' + name
}));
let completions = [];
if (engineItems.length > 0) {
completions.push({
name: 'Search Engines',
items: engineItems
});
}
if (historyItems.length > 0) {
completions.push({
name: 'History',
items: historyItems
});
}
return completions;
});
};
const getCompletions = (line, settings) => {
let typedWords = line.trim().split(/ +/);
let typing = '';
if (!line.endsWith(' ')) {
typing = typedWords.pop();
}
if (typedWords.length === 0) {
return Promise.resolve([]);
}
let name = typedWords.shift();
let keywords = typedWords.concat(typing).join(' ');
switch (name) {
case 'o':
case 'open':
case 't':
case 'tabopen':
case 'w':
case 'winopen':
return getOpenCompletions(name, keywords, settings.search);
case 'b':
case 'buffer':
return tabs.getCompletions(keywords).then((gotTabs) => {
let items = gotTabs.map((tab) => {
return {
caption: tab.title,
content: name + ' ' + tab.title,
url: tab.url,
icon: tab.favIconUrl
};
});
return [
{
name: 'Buffers',
items: items
}
];
});
}
return Promise.resolve([]);
};
const complete = (line, settings) => {
return getCompletions(line, settings);
};
export default complete;

@ -0,0 +1,3 @@
import complete from './complete';
export { complete };

@ -0,0 +1,59 @@
const normalizeUrl = (args, searchConfig) => {
let concat = args.join(' ');
try {
return new URL(concat).href;
} catch (e) {
if (concat.includes('.') && !concat.includes(' ')) {
return 'http://' + concat;
}
let query = concat;
let template = searchConfig.engines[
searchConfig.default
];
for (let key in searchConfig.engines) {
if (args[0] === key) {
query = args.slice(1).join(' ');
template = searchConfig.engines[key];
}
}
return template.replace('{}', encodeURIComponent(query));
}
};
const mustNumber = (v) => {
let num = Number(v);
if (isNaN(num)) {
throw new Error('Not number: ' + v);
}
return num;
};
const parseSetOption = (word, types) => {
let [key, value] = word.split('=');
if (value === undefined) {
value = !key.startsWith('no');
key = value ? key : key.slice(2);
}
let type = types[key];
if (!type) {
throw new Error('Unknown property: ' + key);
}
if (type === 'boolean' && typeof value !== 'boolean' ||
type !== 'boolean' && typeof value === 'boolean') {
throw new Error('Invalid argument: ' + word);
}
switch (type) {
case 'string': return [key, value];
case 'number': return [key, mustNumber(value)];
case 'boolean': return [key, value];
}
};
const parseCommandLine = (line) => {
let words = line.trim().split(/ +/);
let name = words.shift();
return [name, words];
};
export { normalizeUrl, parseCommandLine, parseSetOption };

@ -32,6 +32,7 @@ export default {
CONSOLE_SHOW_ERROR: 'console.show.error', CONSOLE_SHOW_ERROR: 'console.show.error',
CONSOLE_SHOW_INFO: 'console.show.info', CONSOLE_SHOW_INFO: 'console.show.info',
CONSOLE_SHOW_FIND: 'console.show.find', CONSOLE_SHOW_FIND: 'console.show.find',
CONSOLE_HIDE: 'console.hide',
FOLLOW_START: 'follow.start', FOLLOW_START: 'follow.start',
FOLLOW_REQUEST_COUNT_TARGETS: 'follow.request.count.targets', FOLLOW_REQUEST_COUNT_TARGETS: 'follow.request.count.targets',
@ -44,6 +45,8 @@ export default {
FIND_NEXT: 'find.next', FIND_NEXT: 'find.next',
FIND_PREV: 'find.prev', FIND_PREV: 'find.prev',
FIND_GET_KEYWORD: 'find.get.keyword',
FIND_SET_KEYWORD: 'find.set.keyword',
OPEN_URL: 'open.url', OPEN_URL: 'open.url',

@ -1,4 +1,7 @@
export default { export default {
// Hide console, or cancel some user actions
CANCEL: 'cancel',
// Addons // Addons
ADDON_ENABLE: 'addon.enable', ADDON_ENABLE: 'addon.enable',
ADDON_DISABLE: 'addon.disable', ADDON_DISABLE: 'addon.disable',
@ -31,13 +34,18 @@ export default {
NAVIGATE_PARENT: 'navigate.parent', NAVIGATE_PARENT: 'navigate.parent',
NAVIGATE_ROOT: 'navigate.root', NAVIGATE_ROOT: 'navigate.root',
// Focus
FOCUS_INPUT: 'focus.input',
// Tabs // Tabs
TAB_CLOSE: 'tabs.close', TAB_CLOSE: 'tabs.close',
TAB_CLOSE_FORCE: 'tabs.close.force',
TAB_REOPEN: 'tabs.reopen', TAB_REOPEN: 'tabs.reopen',
TAB_PREV: 'tabs.prev', TAB_PREV: 'tabs.prev',
TAB_NEXT: 'tabs.next', TAB_NEXT: 'tabs.next',
TAB_FIRST: 'tabs.first', TAB_FIRST: 'tabs.first',
TAB_LAST: 'tabs.last', TAB_LAST: 'tabs.last',
TAB_PREV_SEL: 'tabs.prevsel',
TAB_RELOAD: 'tabs.reload', TAB_RELOAD: 'tabs.reload',
TAB_PIN: 'tabs.pin', TAB_PIN: 'tabs.pin',
TAB_UNPIN: 'tabs.unpin', TAB_UNPIN: 'tabs.unpin',
@ -49,8 +57,9 @@ export default {
ZOOM_OUT: 'zoom.out', ZOOM_OUT: 'zoom.out',
ZOOM_NEUTRAL: 'zoom.neutral', ZOOM_NEUTRAL: 'zoom.neutral',
// Url yank // Url yank/paste
URLS_YANK: 'urls.yank', URLS_YANK: 'urls.yank',
URLS_PASTE: 'urls.paste',
// Find // Find
FIND_START: 'find.start', FIND_START: 'find.start',

@ -15,8 +15,6 @@ export default {
"j": { "type": "scroll.vertically", "count": 1 }, "j": { "type": "scroll.vertically", "count": 1 },
"h": { "type": "scroll.horizonally", "count": -1 }, "h": { "type": "scroll.horizonally", "count": -1 },
"l": { "type": "scroll.horizonally", "count": 1 }, "l": { "type": "scroll.horizonally", "count": 1 },
"<C-Y>": { "type": "scroll.vertically", "count": -1 },
"<C-E>": { "type": "scroll.vertically", "count": 1 },
"<C-U>": { "type": "scroll.pages", "count": -0.5 }, "<C-U>": { "type": "scroll.pages", "count": -0.5 },
"<C-D>": { "type": "scroll.pages", "count": 0.5 }, "<C-D>": { "type": "scroll.pages", "count": 0.5 },
"<C-B>": { "type": "scroll.pages", "count": -1 }, "<C-B>": { "type": "scroll.pages", "count": -1 },
@ -25,6 +23,7 @@ export default {
"G": { "type": "scroll.bottom" }, "G": { "type": "scroll.bottom" },
"$": { "type": "scroll.end" }, "$": { "type": "scroll.end" },
"d": { "type": "tabs.close" }, "d": { "type": "tabs.close" },
"!d": { "type": "tabs.close.force" },
"u": { "type": "tabs.reopen" }, "u": { "type": "tabs.reopen" },
"K": { "type": "tabs.prev", "count": 1 }, "K": { "type": "tabs.prev", "count": 1 },
"J": { "type": "tabs.next", "count": 1 }, "J": { "type": "tabs.next", "count": 1 },
@ -32,6 +31,7 @@ export default {
"gt": { "type": "tabs.next", "count": 1 }, "gt": { "type": "tabs.next", "count": 1 },
"g0": { "type": "tabs.first" }, "g0": { "type": "tabs.first" },
"g$": { "type": "tabs.last" }, "g$": { "type": "tabs.last" },
"<C-6>": { "type": "tabs.prevsel" },
"r": { "type": "tabs.reload", "cache": false }, "r": { "type": "tabs.reload", "cache": false },
"R": { "type": "tabs.reload", "cache": true }, "R": { "type": "tabs.reload", "cache": true },
"zp": { "type": "tabs.pin.toggle" }, "zp": { "type": "tabs.pin.toggle" },
@ -39,15 +39,18 @@ export default {
"zi": { "type": "zoom.in" }, "zi": { "type": "zoom.in" },
"zo": { "type": "zoom.out" }, "zo": { "type": "zoom.out" },
"zz": { "type": "zoom.neutral" }, "zz": { "type": "zoom.neutral" },
"f": { "type": "follow.start", "newTab": false }, "f": { "type": "follow.start", "newTab": false, "background": false },
"F": { "type": "follow.start", "newTab": true }, "F": { "type": "follow.start", "newTab": true, "background": false },
"H": { "type": "navigate.history.prev" }, "H": { "type": "navigate.history.prev" },
"L": { "type": "navigate.history.next" }, "L": { "type": "navigate.history.next" },
"[[": { "type": "navigate.link.prev" }, "[[": { "type": "navigate.link.prev" },
"]]": { "type": "navigate.link.next" }, "]]": { "type": "navigate.link.next" },
"gu": { "type": "navigate.parent" }, "gu": { "type": "navigate.parent" },
"gU": { "type": "navigate.root" }, "gU": { "type": "navigate.root" },
"gi": { "type": "focus.input" },
"y": { "type": "urls.yank" }, "y": { "type": "urls.yank" },
"p": { "type": "urls.paste", "newTab": false },
"P": { "type": "urls.paste", "newTab": true },
"/": { "type": "find.start" }, "/": { "type": "find.start" },
"n": { "type": "find.next" }, "n": { "type": "find.next" },
"N": { "type": "find.prev" }, "N": { "type": "find.prev" },
@ -63,6 +66,8 @@ export default {
"twitter": "https://twitter.com/search?q={}", "twitter": "https://twitter.com/search?q={}",
"wikipedia": "https://en.wikipedia.org/w/index.php?search={}" "wikipedia": "https://en.wikipedia.org/w/index.php?search={}"
} }
},
"properties": {
} }
}` }`,
}; };

@ -0,0 +1,18 @@
// describe types of a propety as:
// mystr: 'string',
// mynum: 'number',
// mybool: 'boolean',
const types = {
hintchars: 'string',
smoothscroll: 'boolean',
adjacenttab: 'boolean',
};
// describe default values of a property
const defaults = {
hintchars: 'abcdefghijklmnopqrstuvwxyz',
smoothscroll: false,
adjacenttab: true,
};
export { types, defaults };

@ -0,0 +1,36 @@
import DefaultSettings from './default';
import * as settingsValues from './values';
const loadRaw = () => {
return browser.storage.local.get('settings').then(({ settings }) => {
if (!settings) {
return DefaultSettings;
}
return Object.assign({}, DefaultSettings, settings);
});
};
const loadValue = () => {
return loadRaw().then((settings) => {
let value = JSON.parse(DefaultSettings.json);
if (settings.source === 'json') {
value = settingsValues.valueFromJson(settings.json);
} else if (settings.source === 'form') {
value = settingsValues.valueFromForm(settings.form);
}
if (!value.properties) {
value.properties = {};
}
return Object.assign({},
settingsValues.valueFromJson(DefaultSettings.json),
value);
});
};
const save = (settings) => {
return browser.storage.local.set({
settings,
});
};
export { loadRaw, loadValue, save };

@ -1,6 +1,7 @@
import operations from 'shared/operations'; import operations from 'shared/operations';
import * as properties from './properties';
const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist']; const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist', 'properties'];
const VALID_OPERATION_VALUES = Object.keys(operations).map((key) => { const VALID_OPERATION_VALUES = Object.keys(operations).map((key) => {
return operations[key]; return operations[key];
}); });
@ -48,6 +49,17 @@ const validateSearch = (search) => {
} }
}; };
const validateProperties = (props) => {
for (let name of Object.keys(props)) {
if (!properties.types[name]) {
throw new Error(`Unknown property name: "${name}"`);
}
if (typeof props[name] !== properties.types[name]) {
throw new Error(`Invalid type for property: "${name}"`);
}
}
};
const validate = (settings) => { const validate = (settings) => {
validateInvalidTopKeys(settings); validateInvalidTopKeys(settings);
if (settings.keymaps) { if (settings.keymaps) {
@ -56,6 +68,9 @@ const validate = (settings) => {
if (settings.search) { if (settings.search) {
validateSearch(settings.search); validateSearch(settings.search);
} }
if (settings.properties) {
validateProperties(settings.properties);
}
}; };
export { validate }; export { validate };

@ -0,0 +1,108 @@
import * as properties from './properties';
const operationFromFormName = (name) => {
let [type, argStr] = name.split('?');
let args = {};
if (argStr) {
args = JSON.parse(argStr);
}
return Object.assign({ type }, args);
};
const operationToFormName = (op) => {
let type = op.type;
let args = Object.assign({}, op);
delete args.type;
if (Object.keys(args).length === 0) {
return type;
}
return op.type + '?' + JSON.stringify(args);
};
const valueFromJson = (json) => {
return JSON.parse(json);
};
const valueFromForm = (form) => {
let keymaps = undefined;
if (form.keymaps) {
keymaps = {};
for (let name of Object.keys(form.keymaps)) {
let keys = form.keymaps[name];
keymaps[keys] = operationFromFormName(name);
}
}
let search = undefined;
if (form.search) {
search = { default: form.search.default };
if (form.search.engines) {
search.engines = {};
for (let [name, url] of form.search.engines) {
search.engines[name] = url;
}
}
}
return {
keymaps,
search,
blacklist: form.blacklist,
properties: form.properties
};
};
const jsonFromValue = (value) => {
return JSON.stringify(value, undefined, 2);
};
const formFromValue = (value, allowedOps) => {
let keymaps = undefined;
if (value.keymaps) {
let allowedSet = new Set(allowedOps);
keymaps = {};
for (let keys of Object.keys(value.keymaps)) {
let op = operationToFormName(value.keymaps[keys]);
if (allowedSet.has(op)) {
keymaps[op] = keys;
}
}
}
let search = undefined;
if (value.search) {
search = { default: value.search.default };
if (value.search.engines) {
search.engines = Object.keys(value.search.engines).map((name) => {
return [name, value.search.engines[name]];
});
}
}
let formProperties = Object.assign({}, properties.defaults, value.properties);
return {
keymaps,
search,
blacklist: value.blacklist,
properties: formProperties,
};
};
const jsonFromForm = (form) => {
return jsonFromValue(valueFromForm(form));
};
const formFromJson = (json, allowedOps) => {
let value = valueFromJson(json);
return formFromValue(value, allowedOps);
};
export {
valueFromJson, valueFromForm, jsonFromValue, formFromValue,
jsonFromForm, formFromJson
};

@ -1,18 +1,15 @@
import React from 'react'; import { h, Component } from 'preact';
import PropTypes from 'prop-types';
class Provider extends React.PureComponent { class Provider extends Component {
getChildContext() { getChildContext() {
return { store: this.props.store }; return { store: this.props.store };
} }
render() { render() {
return React.Children.only(this.props.children); return <div>
{ this.props.children }
</div>;
} }
} }
Provider.childContextTypes = {
store: PropTypes.any,
};
export default Provider; export default Provider;

Some files were not shown because too many files have changed in this diff Show More