Merge remote-tracking branch 'origin/master' into background-adjacent-tabs

jh-changes
Shin'ya Ueoka 6 years ago
commit 4d7c24f38a
  1. 45
      .circleci/config.yml
  2. 4
      .eslintrc
  3. 1
      .gitignore
  4. 12
      .travis.yml
  5. 89
      QA.md
  6. 54
      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. 31
      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.html
  52. 2
      src/console/index.js
  53. 4
      src/console/reducers/index.js
  54. 7
      src/console/site.scss
  55. 59
      src/content/actions/find.js
  56. 29
      src/content/actions/operation.js
  57. 9
      src/content/actions/setting.js
  58. 20
      src/content/components/common/follow.js
  59. 2
      src/content/components/common/hint.css
  60. 6
      src/content/components/common/input.js
  61. 3
      src/content/components/common/keymapper.js
  62. 13
      src/content/components/top-content/find.js
  63. 20
      src/content/components/top-content/follow-controller.js
  64. 3
      src/content/console-frame.scss
  65. 9
      src/content/console-frames.js
  66. 13
      src/content/focuses.js
  67. 62
      src/content/navigates.js
  68. 2
      src/content/reducers/find.js
  69. 177
      src/content/scrolls.js
  70. 26
      src/content/urls.js
  71. 31
      src/settings/actions/setting.js
  72. 52
      src/settings/components/form/blacklist-form.jsx
  73. 9
      src/settings/components/form/blacklist-form.scss
  74. 109
      src/settings/components/form/keymaps-form.jsx
  75. 11
      src/settings/components/form/keymaps-form.scss
  76. 60
      src/settings/components/form/properties-form.jsx
  77. 12
      src/settings/components/form/properties-form.scss
  78. 78
      src/settings/components/form/search-form.jsx
  79. 28
      src/settings/components/form/search-form.scss
  80. 202
      src/settings/components/index.jsx
  81. 19
      src/settings/components/site.scss
  82. 12
      src/settings/components/ui/add-button.jsx
  83. 13
      src/settings/components/ui/add-button.scss
  84. 12
      src/settings/components/ui/delete-button.jsx
  85. 13
      src/settings/components/ui/delete-button.scss
  86. 52
      src/settings/components/ui/input.jsx
  87. 29
      src/settings/components/ui/input.scss
  88. 5
      src/settings/index.jsx
  89. 2
      src/settings/reducers/setting.js
  90. 181
      src/shared/commands.js
  91. 84
      src/shared/commands/complete.js
  92. 3
      src/shared/commands/index.js
  93. 59
      src/shared/commands/parsers.js
  94. 3
      src/shared/messages.js
  95. 11
      src/shared/operations.js
  96. 12
      src/shared/settings/default.js
  97. 18
      src/shared/settings/properties.js
  98. 36
      src/shared/settings/storage.js
  99. 17
      src/shared/settings/validator.js
  100. 108
      src/shared/settings/values.js
  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-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "off",
"no-bitwise": "off",
"no-console": ["error", { "allow": ["warn", "error"] }],
"no-empty-function": "off",
@ -44,6 +45,8 @@
"no-plusplus": "off",
"no-ternary": "off",
"no-undefined": "off",
"no-undef-init": "off",
"no-unused-vars": ["error", { "varsIgnorePattern": "h" }],
"no-use-before-define": "off",
"no-warning-comments": "off",
"object-curly-newline": ["error", { "consistent": true }],
@ -65,5 +68,6 @@
"react/jsx-indent": ["error", 2],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off"
}
}

1
.gitignore vendored

@ -1,3 +1,4 @@
/node_modules/
/build/
/e2e/ambassador/build/
*.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
### Operations
### Keybindings in JSON settings
Test operations with default key maps.
#### 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
- [ ] <kbd>h</kbd>, <kbd>l</kbd>: scroll left and right
- [ ] <kbd>Ctrl</kbd>+<kbd>U</kbd>, <kbd>Ctrl</kbd>+<kbd>D</kbd>: scroll up and down by half of screen
- [ ] <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
- [ ] Smooth scroll by `:set smoothscroll`
- [ ] Non-smooth scroll by `:set nosmoothscroll`
- [ ] Configure custom hint character by settings `"smoothscroll": true`, `"smoothscroll": false`
#### Console
@ -24,28 +21,26 @@ The behaviors of the console are tested in [Console section](#consoles).
#### Tabs
- [ ] <kbd>d</kbd>: delete current tab
- [ ] <kbd>!d</kbd>: delete current tab and pinned 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 without cache
- [ ] <kbd>zd</kbd>: duplicate current tab
- [ ] <kbd>zp</kbd>: toggle pin/unpin state on current tab
#### 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>g</kbd><kbd>u</kbd>: go to parent directory
- [ ] <kbd>g</kbd><kbd>U</kbd>: go to root directory
#### 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>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>
- [ ] Hide error and info console by <kbd>Esc</kbd>
### 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 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>
- [ ] 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
@ -82,7 +80,7 @@ The behaviors of the console are tested in [Console section](#consoles).
- [ ] `buffer`,`buffer<SP>`: do nothing
- [ ] `buffer <title>`, `buffer <url>`: select tab which has an title matched with
- [ ] `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
### Completions
@ -109,20 +107,22 @@ The behaviors of the console are tested in [Console section](#consoles).
### Settings
#### Validations
#### JSON Settings
##### Validations
- [ ] 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"`
##### `"search"` section
###### `"search"` section
- 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 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 `gist.github.com/`, and not `github.com`
#### Updating
##### Updating
- [ ] changes are updated on textarea blure when no errors
- [ ] changes are not updated on textarea blure when errors occurs
- [ ] keymap 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
- [ ] scoll on Hacker News
- [ ] scroll on Hacker News
- [ ] 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
- [ ] Tha pages is shown in https://pitchify.com/
- [ ] Open console in http://www.espncricinfo.com/
## Find mode
- [ ] 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>
- [ ] Wrap search by <kbd>n</kbd>/<kbd>N</kbd>
- [ ] Find with last keyword if keyword is empty
- [ ] Find keyword last used on new tab opened
## Misc

@ -1,5 +1,11 @@
# 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.
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.
@ -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>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
- <kbd>d</kbd>: delete current tab
- <kbd>!</kbd><kbd>d</kbd>: delete pinned 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 without cache
- <kbd>zp</kbd>: toggle pin/unpin current tab
- <kbd>zd</kbd>: duplicate current tab
### Navigation
- <kbd>f</kbd>: start following links in the page
- <kbd>H</kbd>: go back in histories
- <kbd>L</kbd>: go forward in histories
- <kbd>H</kbd>: go back in history
- <kbd>L</kbd>: go forward in history
- <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 root directory
- <kbd>g</kbd><kbd>i</kbd>: focus first input
#### 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>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>/</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
@ -94,6 +92,26 @@ Open a URL or search keywords by search engine in new window.
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
```
### Search engines
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'],
files: [
'test/**/*.test.js',
'test/**/*.test.jsx',
'test/**/*.html'
],
preprocessors: {
'test/**/*.test.js': [ 'webpack' ],
'test/**/*.test.jsx': [ 'webpack' ],
'test/**/*.html': ['html2js']
},

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "Vim Vixen",
"description": "Vim Vixen",
"version": "0.5",
"version": "0.12",
"icons": {
"48": "resources/icon_48x48.png",
"96": "resources/icon_96x96.png"
@ -30,7 +30,8 @@
"history",
"sessions",
"storage",
"tabs"
"tabs",
"clipboardRead"
],
"web_accessible_resources": [
"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",
"description": "Vim vixen",
"scripts": {
"start": "webpack -w --debug --devtool inline-source-map",
"lint": "eslint --ext .jsx,.js src",
"build": "NODE_ENV=production webpack --progress --display-error-details",
"package": "npm run build && ./package.sh",
"lint": "eslint src",
"test": "karma start"
"start": "webpack --mode development -w --debug --devtool inline-source-map",
"build": "NODE_ENV=production webpack --mode production --progress --display-error-details",
"package": "npm run build && script/package.sh",
"lint": "eslint --ext .jsx,.js src e2e/ambassador/src",
"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": {
"type": "git",
@ -20,31 +22,30 @@
},
"homepage": "https://github.com/ueokande/vim-vixen",
"devDependencies": {
"adm-zip": "^0.4.7",
"babel-cli": "^6.24.1",
"babel-eslint": "^7.2.3",
"babel-eslint": "^8.0.2",
"babel-loader": "^7.1.1",
"babel-minify-webpack-plugin": "^0.2.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-preact": "^1.1.0",
"chai": "^4.1.1",
"css-loader": "^0.28.4",
"css-loader": "^0.28.10",
"eslint": "^4.7.0",
"eslint-plugin-react": "^7.4.0",
"html-webpack-plugin": "^2.30.1",
"karma": "^1.7.0",
"html-webpack-plugin": "^3.0.6",
"karma": "^2.0.0",
"karma-firefox-launcher": "^1.0.1",
"karma-html2js-preprocessor": "^1.1.0",
"karma-mocha": "^1.3.0",
"karma-mocha-reporter": "^2.2.3",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^2.0.4",
"mocha": "^3.5.0",
"karma-webpack": "^3.0.0",
"mocha": "^5.0.0",
"node-sass": "^4.5.3",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"webpack": "^3.5.3"
"preact": "^8.2.6",
"sass-loader": "^7.0.1",
"style-loader": "^0.21.0",
"web-ext": "github:ueokande/web-ext#patched-2.4.0",
"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) {
case operations.TAB_CLOSE:
return tabs.closeTab(tab.id);
case operations.TAB_CLOSE_FORCE:
return tabs.closeTabForce(tab.id);
case operations.TAB_REOPEN:
return tabs.reopenTab();
case operations.TAB_PREV:
@ -27,6 +29,8 @@ const exec = (operation, tab) => {
return tabs.selectFirstTab();
case operations.TAB_LAST:
return tabs.selectLastTab();
case operations.TAB_PREV_SEL:
return tabs.selectPrevSelTab();
case operations.TAB_RELOAD:
return tabs.reload(tab, operation.cache);
case operations.TAB_PIN:
@ -69,6 +73,10 @@ const exec = (operation, tab) => {
return browser.tabs.sendMessage(tab.id, {
type: messages.CONSOLE_SHOW_FIND
});
case operations.CANCEL:
return browser.tabs.sendMessage(tab.id, {
type: messages.CONSOLE_HIDE,
});
default:
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,5 +1,21 @@
const openNewTab = (url, openerTabId, background = false, adjacent = false) => {
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) => {
return browser.tabs.update(tab.id, { url: url });
};
export { openToTab };
export { openNewTab, openToTab };

@ -1,6 +1,8 @@
import messages from 'shared/messages';
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 commands from 'shared/commands';
@ -22,6 +24,8 @@ export default class BackgroundComponent {
onMessage(message, sender) {
let settings = this.store.getState().setting;
let find = this.store.getState().find;
switch (message.type) {
case messages.BACKGROUND_OPERATION:
return this.store.dispatch(
@ -29,26 +33,31 @@ export default class BackgroundComponent {
sender);
case messages.OPEN_URL:
if (message.newTab) {
return this.store.dispatch(
commands.tabopenCommand(message.url, message.background,
settings.value.openAdjacentTabs), sender);
let action = tabActions.openNewTab(
message.url, sender.tab.id, message.background,
settings.value.properties.adjacenttab);
return this.store.dispatch(action, sender);
}
return this.store.dispatch(
tabActions.openToTab(message.url, sender.tab), sender);
case messages.CONSOLE_ENTER_COMMAND:
return commands.exec(message.text, settings.value).catch((e) => {
return browser.tabs.sendMessage(sender.tab.id, {
type: messages.CONSOLE_SHOW_ERROR,
text: e.message,
});
});
this.store.dispatch(
commandActions.exec(message.text, settings.value),
sender
);
return this.broadcastSettingsChanged();
case messages.SETTINGS_QUERY:
return Promise.resolve(this.store.getState().setting.value);
case messages.CONSOLE_QUERY_COMPLETIONS:
return commands.complete(message.text, settings.value);
case messages.SETTINGS_RELOAD:
this.store.dispatch(settingsActions.load());
this.store.dispatch(settingActions.load());
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)
.slice(0, 10)
.map(item => item[0])
.sort((x, y) => x.url > y.url)
)[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 BackgroundComponent from 'background/components/background';
import reducers from 'background/reducers';
@ -16,4 +16,4 @@ const store = createStore(reducers, (e, sender) => {
// eslint-disable-next-line no-unused-vars
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
const defaultState = {
setting: settingReducer(undefined, {}),
find: findReducer(undefined, {}),
};
export default function reducer(state = defaultState, action = {}) {
return Object.assign({}, state, {
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) => {
return browser.tabs.get(id).then((tab) => {
if (!tab.pinned) {
return browser.tabs.remove(id);
}
});
};
const closeTabForce = (id) => {
return browser.tabs.remove(id);
};
@ -51,7 +69,7 @@ const selectByKeyword = (current, keyword) => {
const getCompletions = (keyword) => {
return browser.tabs.query({ currentWindow: true }).then((tabs) => {
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;
});
@ -93,6 +111,10 @@ const selectLastTab = () => {
});
};
const selectPrevSelTab = () => {
return browser.tabs.update(prevSelTab, { active: true });
};
const reload = (current, cache) => {
return browser.tabs.reload(
current.id,
@ -116,7 +138,8 @@ const duplicate = (id) => {
};
export {
closeTab, reopenTab, selectAt, selectByKeyword, getCompletions,
selectPrevTab, selectNextTab, selectFirstTab, selectLastTab, reload,
updateTabPinned, toggleTabPinned, duplicate
closeTab, closeTabForce, reopenTab, selectAt, selectByKeyword,
getCompletions, selectPrevTab, selectNextTab, selectFirstTab,
selectLastTab, selectPrevSelTab, reload, updateTabPinned,
toggleTabPinned, duplicate
};

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

@ -1,5 +1,6 @@
export default {
// console commands
CONSOLE_HIDE: 'console.hide',
CONSOLE_SHOW_COMMAND: 'console.show.command',
CONSOLE_SHOW_ERROR: 'console.show.error',
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) {
if (e.keyCode === KeyboardEvent.DOM_VK_ESCAPE && e.ctrlKey) {
return this.hideCommand();
}
switch (e.keyCode) {
case KeyboardEvent.DOM_VK_ESCAPE:
return this.hideCommand();
case KeyboardEvent.DOM_VK_RETURN:
e.stopPropagation();
e.preventDefault();
return this.onEntered(e.target.value);
return this.doEnter(e);
case KeyboardEvent.DOM_VK_TAB:
if (e.shiftKey) {
this.store.dispatch(consoleActions.completionPrev());
@ -48,6 +67,26 @@ export default class ConsoleComponent {
e.stopPropagation();
e.preventDefault();
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;
}
}

@ -7,7 +7,7 @@
</head>
<body class='vimvixen-console'>
<p class='vimvixen-console-message'></p>
<div id='vimvixen-console-command'>
<div id='vimvixen-console-command' class='vimvixen-console-command-wrapper'>
<ul id='vimvixen-console-completion' class='vimvixen-console-completion'></ul>
<div class='vimvixen-console-command'>
<i class='vimvixen-console-command-prompt'></i><input

@ -24,6 +24,8 @@ const onMessage = (message) => {
return store.dispatch(consoleActions.showError(message.text));
case messages.CONSOLE_SHOW_INFO:
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 = {}) {
switch (action.type) {
case actions.CONSOLE_HIDE:
return Object.assign({}, state, {
mode: '',
});
case actions.CONSOLE_SHOW_COMMAND:
return Object.assign({}, state, {
mode: 'command',

@ -12,7 +12,6 @@ body {
}
.vimvixen-console {
border-top: 1px solid gray;
bottom: 0;
margin: 0;
padding: 0;
@ -24,6 +23,10 @@ body {
line-height: 16px;
}
&-command-wrapper {
border-top: 1px solid gray;
}
&-completion {
background-color: white;
@ -66,6 +69,8 @@ body {
&-message {
@include consoole-font;
border-top: 1px solid gray;
}
&-error {

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

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

@ -1,10 +1,17 @@
import actions from 'content/actions';
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) => {
let entries = [];
if (value.keymaps) {
entries = Object.entries(value.keymaps).map((entry) => {
let keymaps = Object.assign({}, value.keymaps, reservedKeymaps);
entries = Object.entries(keymaps).map((entry) => {
return [
keyUtils.fromMapKeys(entry[0]),
entry[1],

@ -4,7 +4,8 @@ import * as dom from 'shared/utils/dom';
const TARGET_SELECTOR = [
'a', 'button', 'input', 'textarea', 'area',
'[contenteditable=true]', '[contenteditable=""]', '[tabindex]'
'[contenteditable=true]', '[contenteditable=""]', '[tabindex]',
'[role="button"]'
].join(',');
@ -29,6 +30,21 @@ const inViewport = (win, element, viewSize, framePosition) => {
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 {
constructor(win, store) {
this.win = win;
@ -48,6 +64,7 @@ export default class Follow {
this.win.parent.postMessage(JSON.stringify({
type: messages.FOLLOW_KEY_PRESS,
key: key.key,
ctrlKey: key.ctrlKey,
}), '*');
return true;
}
@ -174,6 +191,7 @@ export default class Follow {
style.visibility !== 'hidden' &&
element.type !== 'hidden' &&
element.offsetHeight > 0 &&
!isAriaHiddenOrAriaDisabled(win, element) &&
inViewport(win, element, viewSize, framePosition);
});
return filtered;

@ -4,7 +4,7 @@
font-weight: bold;
position: absolute;
text-transform: uppercase;
z-index: 100000;
z-index: 2147483647;
font-size: 12px;
color: black;
}

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

@ -47,7 +47,8 @@ export default class KeymapperComponent {
return true;
}
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());
return true;
}

@ -1,6 +1,5 @@
import * as findActions from 'content/actions/find';
import messages from 'shared/messages';
import * as consoleFrames from '../../console-frames';
export default class FindComponent {
constructor(win, store) {
@ -32,23 +31,11 @@ export default class FindComponent {
next() {
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));
}
prev() {
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));
}
}

@ -1,8 +1,7 @@
import * as followControllerActions from 'content/actions/follow-controller';
import messages from 'shared/messages';
import HintKeyProducer from 'content/hint-key-producer';
const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz';
import * as properties from 'shared/settings/properties';
const broadcastMessage = (win, message) => {
let json = JSON.stringify(message);
@ -33,7 +32,7 @@ export default class FollowController {
case messages.FOLLOW_RESPONSE_COUNT_TARGETS:
return this.create(message.count, sender);
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) {
case 'Enter':
this.activate();
@ -84,7 +87,7 @@ export default class FollowController {
this.store.dispatch(followControllerActions.backspace());
break;
default:
if (DEFAULT_HINT_CHARSET.includes(key)) {
if (this.hintchars().includes(key)) {
this.store.dispatch(followControllerActions.keyPress(key));
}
break;
@ -93,7 +96,7 @@ export default class FollowController {
}
count() {
this.producer = new HintKeyProducer(DEFAULT_HINT_CHARSET);
this.producer = new HintKeyProducer(this.hintchars());
let doc = this.win.document;
let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth;
let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight;
@ -136,4 +139,9 @@ export default class FollowController {
type: messages.FOLLOW_REMOVE_HINTS,
});
}
hintchars() {
return this.store.getState().setting.properties.hintchars ||
properties.defaults.hintchars;
}
}

@ -6,7 +6,8 @@
width: 100%;
height: 100%;
position: fixed;
z-index: 10000;
z-index: 2147483647;
border: none;
background-color: unset;
pointer-events:none;
}

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

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

@ -1,27 +1,14 @@
import * as doms from 'shared/utils/dom';
const SCROLL_DELTA_X = 48;
const SCROLL_DELTA_Y = 48;
const SMOOTH_SCROLL_DURATION = 150;
const isVisible = (win, element) => {
let rect = element.getBoundingClientRect();
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;
};
// dirty way to store scrolling state on globally
let scrolling = [false];
const isScrollableStyle = (win, element) => {
let { overflowX, overflowY } = win.getComputedStyle(element);
const isScrollableStyle = (element) => {
let { overflowX, overflowY } = window.getComputedStyle(element);
return !(overflowX !== 'scroll' && overflowX !== '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
// method is not cached. That does not cause performance issue because in the
// most pages, the window is root element i,e, documentElement.
const findScrollable = (win, element) => {
if (isScrollableStyle(win, element) && isOverflowed(element)) {
const findScrollable = (element) => {
if (isScrollableStyle(element) && isOverflowed(element)) {
return element;
}
let children = Array.prototype
.filter.call(element.children, e => isVisible(win, e));
let children = Array.from(element.children).filter(doms.isVisible);
for (let child of children) {
let scrollable = findScrollable(win, child);
let scrollable = findScrollable(child);
if (scrollable) {
return scrollable;
}
@ -51,68 +37,153 @@ const findScrollable = (win, element) => {
return null;
};
const scrollTarget = (win) => {
if (isOverflowed(win.document.documentElement)) {
return win.document.documentElement;
const scrollTarget = () => {
if (isOverflowed(window.document.documentElement)) {
return window.document.documentElement;
}
if (isOverflowed(win.document.body)) {
return win.document.body;
if (isOverflowed(window.document.body)) {
return window.document.body;
}
let target = findScrollable(win, win.document.documentElement);
let target = findScrollable(window.document.documentElement);
if (target) {
return target;
}
return win.document.documentElement;
return window.document.documentElement;
};
const scrollVertically = (win, count) => {
let target = scrollTarget(win);
class SmoothScroller {
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 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) => {
let target = scrollTarget(win);
const scrollHorizonally = (count, smooth, repeat) => {
let target = scrollTarget();
let x = target.scrollLeft + SCROLL_DELTA_X * count;
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) => {
let target = scrollTarget(win);
const scrollPages = (count, smooth, repeat) => {
let target = scrollTarget();
let height = target.clientHeight;
let x = target.scrollLeft;
let y = target.scrollTop + height * count;
target.scrollTo(x, y);
scroller(target, smooth, repeat).scroll(x, y);
};
const scrollTop = (win) => {
let target = scrollTarget(win);
const scrollTop = (smooth, repeat) => {
let target = scrollTarget();
let x = target.scrollLeft;
let y = 0;
target.scrollTo(x, y);
scroller(target, smooth, repeat).scroll(x, y);
};
const scrollBottom = (win) => {
let target = scrollTarget(win);
const scrollBottom = (smooth, repeat) => {
let target = scrollTarget();
let x = target.scrollLeft;
let y = target.scrollHeight;
target.scrollTo(x, y);
scroller(target, smooth, repeat).scroll(x, y);
};
const scrollHome = (win) => {
let target = scrollTarget(win);
const scrollHome = (smooth, repeat) => {
let target = scrollTarget();
let x = 0;
let y = target.scrollTop;
target.scrollTo(x, y);
scroller(target, smooth, repeat).scroll(x, y);
};
const scrollEnd = (win) => {
let target = scrollTarget(win);
const scrollEnd = (smooth, repeat) => {
let target = scrollTarget();
let x = target.scrollWidth;
let y = target.scrollTop;
target.scrollTo(x, y);
scroller(target, smooth, repeat).scroll(x, y);
};
export {

@ -1,3 +1,5 @@
import messages from 'shared/messages';
const yank = (win) => {
let input = win.document.createElement('input');
win.document.body.append(input);
@ -12,4 +14,26 @@ const yank = (win) => {
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 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 = () => {
return browser.storage.local.get('settings').then(({ settings }) => {
if (settings) {
return set(settings);
}
return set(DefaultSettings);
}, console.error);
return settingsStorage.loadRaw().then((settings) => {
return set(settings);
});
};
const save = (settings) => {
return browser.storage.local.set({
settings,
}).then(() => {
return settingsStorage.save(settings).then(() => {
return browser.runtime.sendMessage({
type: messages.SETTINGS_RELOAD
}).then(() => {
return set(settings);
});
}).then(() => {
return 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 {
type: actions.SETTING_SET_SETTINGS,
source: settings.source,
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 React from 'react';
import PropTypes from 'prop-types';
import { h, Component } from 'preact';
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 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) {
super(props, context);
this.state = {
settings: {
json: '',
},
errors: {
json: '',
}
};
this.context.store.subscribe(this.stateChanged.bind(this));
@ -26,66 +39,187 @@ class SettingsComponent extends React.Component {
settings: {
source: settings.source,
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() {
let fields = null;
if (this.state.settings.source === 'form') {
fields = this.renderFormFields();
} else if (this.state.settings.source === 'json') {
fields = this.renderJsonFields();
}
return (
<div>
<h1>Configure Vim-Vixen</h1>
<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 type='radio' id='setting-source-json'
<Input
type='radio'
name='source'
label='Use plain JSON'
checked={this.state.settings.source === 'json'}
value='json'
onChange={this.bindAndSave.bind(this)}
checked={this.state.settings.source === 'json'} />
<label htmlFor='settings-source-json'>JSON</label>
<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} />
onChange={this.bindSource.bind(this)} />
{ fields }
</form>
</div>
);
}
validate(e) {
try {
let settings = JSON.parse(e.target.value);
validate(target) {
if (target.name === 'json') {
let settings = JSON.parse(target.value);
validator.validate(settings);
e.target.setCustomValidity('');
}
}
validateValue(e) {
let next = Object.assign({}, this.state);
next.errors.json = '';
try {
this.validate(e.target);
} 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) {
let nextSettings = Object.assign({}, this.state.settings);
nextSettings[e.target.name] = e.target.value;
let next = Object.assign({}, this.state);
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.bindValue(e);
this.setState(this.state);
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 {
let json = this.state.settings.json;
validator.validate(JSON.parse(json));
this.context.store.dispatch(settingActions.save(this.state.settings));
validator.validate(JSON.parse(this.state.settings.json));
} 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 = {
store: PropTypes.any,
};
bindSource(e) {
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;

@ -1,8 +1,27 @@
.vimvixen-settings-form {
padding: 2px;
textarea[name=json] {
font-family: monospace;
width: 100%;
min-height: 64ex;
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 ReactDOM from 'react-dom';
import { h, render } from 'preact';
import SettingsComponent from './components';
import reducer from 'settings/reducers/setting';
import Provider from 'shared/store/provider';
@ -9,7 +8,7 @@ const store = createStore(reducer);
document.addEventListener('DOMContentLoaded', () => {
let wrapper = document.getElementById('vimvixen-settings');
ReactDOM.render(
render(
<Provider store={store}>
<SettingsComponent />
</Provider>,

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

@ -1,181 +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, background = false, adjacent = false) => {
if (adjacent) {
return browser.tabs.query({
active: true, currentWindow: true
}).then((gotTabs) => {
return browser.tabs.create({
url: url,
active: !background,
index: gotTabs[0].index + 1
});
});
}
return browser.tabs.create({ url: url, active: !background });
};
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), false, settings.openAdjacentTabs);
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, tabopenCommand };

@ -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_INFO: 'console.show.info',
CONSOLE_SHOW_FIND: 'console.show.find',
CONSOLE_HIDE: 'console.hide',
FOLLOW_START: 'follow.start',
FOLLOW_REQUEST_COUNT_TARGETS: 'follow.request.count.targets',
@ -44,6 +45,8 @@ export default {
FIND_NEXT: 'find.next',
FIND_PREV: 'find.prev',
FIND_GET_KEYWORD: 'find.get.keyword',
FIND_SET_KEYWORD: 'find.set.keyword',
OPEN_URL: 'open.url',

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

@ -15,8 +15,6 @@ export default {
"j": { "type": "scroll.vertically", "count": 1 },
"h": { "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-D>": { "type": "scroll.pages", "count": 0.5 },
"<C-B>": { "type": "scroll.pages", "count": -1 },
@ -25,11 +23,13 @@ export default {
"G": { "type": "scroll.bottom" },
"$": { "type": "scroll.end" },
"d": { "type": "tabs.close" },
"!d": { "type": "tabs.close.force" },
"u": { "type": "tabs.reopen" },
"K": { "type": "tabs.prev", "count": 1 },
"J": { "type": "tabs.next", "count": 1 },
"g0": { "type": "tabs.first" },
"g$": { "type": "tabs.last" },
"<C-6>": { "type": "tabs.prevsel" },
"r": { "type": "tabs.reload", "cache": false },
"R": { "type": "tabs.reload", "cache": true },
"zp": { "type": "tabs.pin.toggle" },
@ -45,7 +45,10 @@ export default {
"]]": { "type": "navigate.link.next" },
"gu": { "type": "navigate.parent" },
"gU": { "type": "navigate.root" },
"gi": { "type": "focus.input" },
"y": { "type": "urls.yank" },
"p": { "type": "urls.paste", "newTab": false },
"P": { "type": "urls.paste", "newTab": true },
"/": { "type": "find.start" },
"n": { "type": "find.next" },
"N": { "type": "find.prev" },
@ -62,6 +65,7 @@ export default {
"wikipedia": "https://en.wikipedia.org/w/index.php?search={}"
}
},
"openAdjacentTabs": false
}`
"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: false,
};
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 * as properties from './properties';
const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist', 'openAdjacentTabs'];
const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist', 'properties'];
const VALID_OPERATION_VALUES = Object.keys(operations).map((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) => {
validateInvalidTopKeys(settings);
if (settings.keymaps) {
@ -56,6 +68,9 @@ const validate = (settings) => {
if (settings.search) {
validateSearch(settings.search);
}
if (settings.properties) {
validateProperties(settings.properties);
}
};
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
};

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