diff options
46 files changed, 948 insertions, 287 deletions
@@ -1,10 +1,10 @@ -### Checklist for testing Vim Vixen +## Checklist for testing Vim Vixen -#### Operations +### Operations Test operations with default key maps. -##### Scrolling +#### Scrolling - [ ] <kbd>k</kbd> or <kbd>Ctrl</kbd>+<kbd>Y</kbd>, <kbd>j</kbd> or <kbd>Ctrl</kbd>+<kbd>E</kbd>: scroll up and down - [ ] <kbd>h</kbd>, <kbd>l</kbd>: scroll left and right @@ -13,7 +13,7 @@ Test operations with default key maps. - [ ] <kbd>0</kbd>, <kbd>$</kbd>: scroll to leftmost and rightmost - [ ] <kbd>g</kbd><kbd>g</kbd>, <kbd>G</kbd>: scroll to top and bottom -##### Console +#### Console The behaviors of the console are tested in [Console section](#consoles). @@ -22,32 +22,47 @@ The behaviors of the console are tested in [Console section](#consoles). - [ ] <kbd>O</kbd>, <kbd>T</kbd>, <kbd>W</kbd>: open a console with `open`, `tabopen`, `winopen` and current URL - [ ] <kbd>b</kbd>: open a consolw with `buffer` -##### Tabs +#### Tabs - [ ] <kbd>d</kbd>: delete current 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 +#### Navigation -- [ ] <kbd>f</kbd>: start following links -- [ ] <kbd>F</kbd>: start following links and open in new tab - [ ] <kbd>H</kbd>, <kbd>L</kbd>: go back and forward in histories - [ ] <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 +#### 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 +- [ ] Toggle enabled/disabled of plugin bu <kbd>Shift</kbd>+<kbd>Esc</kbd> -#### Consoles +### Following links -##### Exec a command +- [ ] <kbd>f</kbd>: start following links +- [ ] <kbd>F</kbd>: start following links and open in new tab +- [ ] open link with target='_blank' in new tab by <kbd>f</kbd> +- [ ] open link with target='_blank' in new tab by <kbd>F</kbd> +- [ ] Show hints on following on a page containing `<frame>`/`<iframe>` +- [ ] Show hints only inside viewport of the frame on following on a page containing `<frame>`/`<iframe>` +- [ ] Show hints only inside top window on following on a page containing `<frame>`/`<iframe>` +- [ ] 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> + +### Consoles + +#### Exec a command - [ ] `<EMPTY>`, `<SP>`: do nothing <br> @@ -70,9 +85,9 @@ The behaviors of the console are tested in [Console section](#consoles). - [ ] `buffer 0`, `buffer 99`: shows an error - [ ] select tabs rotationally when more than two tabs are matched -#### Completions +### Completions -##### History and search engines +#### History and search engines - [ ] `open`: show no completions - [ ] `open<SP>`: show all engines and some history items @@ -80,16 +95,21 @@ The behaviors of the console are tested in [Console section](#consoles). - [ ] `open foo bar`: complete history items matched with keywords `foo` and `bar` - [ ] also `tabopen` and `winopen` - shortening commands such as `o` are not test in this release +- [ ] Show competions for `:open`/`:tabopen`/`:buffer` on opning just after closed -##### Buffer command +#### Buffer command - [ ] `buffer`: show no completions - [ ] `buffer<SP>`: show all opened tabs in completion - [ ] `buffer x`: show tabs which has title and URL matches with `x` -#### Settings +#### Misc + +- [ ] Select next item by <kbd>Tab</kbd> and previous item by <kbd>Shift</kbd>+<kbd>Tab</kbd> -##### Validations +### Settings + +#### Validations - [ ] show error on invalid json - [ ] show error when top-level keys has keys other than `keymaps`, `search`, and `blacklist` @@ -102,47 +122,39 @@ The behaviors of the console are tested in [Console section](#consoles). - validations in `"search"` section are not tested in this release -##### Updating +#### `"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` +- [ ] `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 - [ ] 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 -#### Events are fired on Slack and Twitter (#54) +### For certain sites +- [ ] scoll 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 the text box on Twitter or Slack on following mode -#### Multi frame support (#61) - -- [ ] Show hints on following on a page containing `<frame>`/`<iframe>` -- [ ] Show hints only inside viewport of the frame on following on a page containing `<frame>`/`<iframe>` -- [ ] Show hints only inside top window on following on a page containing `<frame>`/`<iframe>` -- [ ] 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> - -#### Empty suggestion (#65) - -- [ ] Show competions for `:open`/`:tabopen`/`:buffer` on console after closed +## Find mode -#### Disable add-on temporary (#86) +- [ ] open console with <kbd>/</kbd> +- [ ] highlight a word on <kbd>Enter</kb> 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 -- [ ] Toggle enabled/disabled of plugin bu <kbd>Shift</kbd>+<kbd>Esc</kbd> - -#### URL blacklist (#90) +## Misc -- [ ] `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` - -#### Improve for aberration pages (#93) - -- [ ] able to scroll on Gmail and Slack - -#### Link with target='_blank' link (#94) - -- [ ] open link with target='_blank' in new tab by <kbd>f</kbd> -- [ ] open link with target='_blank' in new tab by <kbd>F</kbd> +- [ ] Work after plugin reload +- [ ] Work on `about:blank` +- [ ] Able to map `<A-Z>` key. +- [ ] Open file menu by <kbd>Alt</kbd>+<kbd>F</kbd> (Other than Mac OS) @@ -32,8 +32,11 @@ The default mappings are as follows: - <kbd>d</kbd>: delete current 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 diff --git a/manifest.json b/manifest.json index 268dae8..5fd1c1a 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "Vim Vixen", "description": "Vim Vixen", - "version": "0.3", + "version": "0.5", "icons": { "48": "resources/icon_48x48.png", "96": "resources/icon_96x96.png" @@ -17,7 +17,8 @@ "all_frames": true, "matches": [ "<all_urls>" ], "js": [ "build/content.js" ], - "run_at": "document_end" + "run_at": "document_end", + "match_about_blank": true } ], "background": { diff --git a/src/background/actions/operation.js b/src/background/actions/operation.js index b94c117..1e4990c 100644 --- a/src/background/actions/operation.js +++ b/src/background/actions/operation.js @@ -10,6 +10,9 @@ const sendConsoleShowCommand = (tab, command) => { }); }; +// This switch statement is only gonna get longer as more +// features are added, so disable complexity check +/* eslint-disable complexity */ const exec = (operation, tab) => { switch (operation.type) { case operations.TAB_CLOSE: @@ -20,8 +23,20 @@ const exec = (operation, tab) => { return tabs.selectPrevTab(tab.index, operation.count); case operations.TAB_NEXT: return tabs.selectNextTab(tab.index, operation.count); + case operations.TAB_FIRST: + return tabs.selectFirstTab(); + case operations.TAB_LAST: + return tabs.selectLastTab(); case operations.TAB_RELOAD: return tabs.reload(tab, operation.cache); + case operations.TAB_PIN: + return tabs.updateTabPinned(tab, true); + case operations.TAB_UNPIN: + return tabs.updateTabPinned(tab, false); + case operations.TAB_TOGGLE_PINNED: + return tabs.toggleTabPinned(tab); + case operations.TAB_DUPLICATE: + return tabs.duplicate(tab.id); case operations.ZOOM_IN: return zooms.zoomIn(); case operations.ZOOM_OUT: @@ -50,9 +65,14 @@ const exec = (operation, tab) => { return sendConsoleShowCommand(tab, 'winopen '); case operations.COMMAND_SHOW_BUFFER: return sendConsoleShowCommand(tab, 'buffer '); + case operations.FIND_START: + return browser.tabs.sendMessage(tab.id, { + type: messages.CONSOLE_SHOW_FIND + }); default: return Promise.resolve(); } }; +/* eslint-enable complexity */ export { exec }; diff --git a/src/background/components/background.js b/src/background/components/background.js index a5f4f5f..2d94310 100644 --- a/src/background/components/background.js +++ b/src/background/components/background.js @@ -34,11 +34,7 @@ export default class BackgroundComponent { } return this.store.dispatch( tabActions.openToTab(message.url, sender.tab), sender); - case messages.CONSOLE_BLURRED: - return browser.tabs.sendMessage(sender.tab.id, { - type: messages.CONSOLE_HIDE_COMMAND, - }); - case messages.CONSOLE_ENTERED: + 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, diff --git a/src/background/tabs.js b/src/background/tabs.js index eed3252..d641616 100644 --- a/src/background/tabs.js +++ b/src/background/tabs.js @@ -79,6 +79,20 @@ const selectNextTab = (current, count) => { }); }; +const selectFirstTab = () => { + return browser.tabs.query({ currentWindow: true }).then((tabs) => { + let id = tabs[0].id; + return browser.tabs.update(id, { active: true }); + }); +}; + +const selectLastTab = () => { + return browser.tabs.query({ currentWindow: true }).then((tabs) => { + let id = tabs[tabs.length - 1].id; + return browser.tabs.update(id, { active: true }); + }); +}; + const reload = (current, cache) => { return browser.tabs.reload( current.id, @@ -86,7 +100,23 @@ const reload = (current, cache) => { ); }; +const updateTabPinned = (current, pinned) => { + return browser.tabs.query({ currentWindow: true, active: true }) + .then(() => { + return browser.tabs.update(current.id, { pinned: pinned }); + }); +}; + +const toggleTabPinned = (current) => { + updateTabPinned(current, !current.pinned); +}; + +const duplicate = (id) => { + return browser.tabs.duplicate(id); +}; + export { closeTab, reopenTab, selectAt, selectByKeyword, getCompletions, - selectPrevTab, selectNextTab, reload + selectPrevTab, selectNextTab, selectFirstTab, selectLastTab, reload, + updateTabPinned, toggleTabPinned, duplicate }; diff --git a/src/console/actions/console.js b/src/console/actions/console.js index 0d891bb..2cf8e8d 100644 --- a/src/console/actions/console.js +++ b/src/console/actions/console.js @@ -7,6 +7,12 @@ const showCommand = (text) => { }; }; +const showFind = () => { + return { + type: actions.CONSOLE_SHOW_FIND, + }; +}; + const showError = (text) => { return { type: actions.CONSOLE_SHOW_ERROR, @@ -27,10 +33,18 @@ const hideCommand = () => { }; }; -const setCompletions = (completions) => { +const setConsoleText = (consoleText) => { + return { + type: actions.CONSOLE_SET_CONSOLE_TEXT, + consoleText, + }; +}; + +const setCompletions = (completionSource, completions) => { return { type: actions.CONSOLE_SET_COMPLETIONS, - completions: completions + completionSource, + completions, }; }; @@ -47,6 +61,6 @@ const completionPrev = () => { }; export { - showCommand, showError, showInfo, hideCommand, + showCommand, showFind, showError, showInfo, hideCommand, setConsoleText, setCompletions, completionNext, completionPrev }; diff --git a/src/console/actions/index.js b/src/console/actions/index.js index c4f88cd..a85e329 100644 --- a/src/console/actions/index.js +++ b/src/console/actions/index.js @@ -4,7 +4,9 @@ export default { CONSOLE_SHOW_ERROR: 'console.show.error', CONSOLE_SHOW_INFO: 'console.show.info', CONSOLE_HIDE_COMMAND: 'console.hide.command', + CONSOLE_SET_CONSOLE_TEXT: 'console.set.command', CONSOLE_SET_COMPLETIONS: 'console.set.completions', CONSOLE_COMPLETION_NEXT: 'console.completion.next', CONSOLE_COMPLETION_PREV: 'console.completion.prev', + CONSOLE_SHOW_FIND: 'console.show.find', }; diff --git a/src/console/components/console.js b/src/console/components/console.js index 5028e2a..7bc3364 100644 --- a/src/console/components/console.js +++ b/src/console/components/console.js @@ -1,45 +1,44 @@ import messages from 'shared/messages'; import * as consoleActions from 'console/actions/console'; +const inputShownMode = (state) => { + return ['command', 'find'].includes(state.mode); +}; + export default class ConsoleComponent { constructor(wrapper, store) { this.wrapper = wrapper; - this.prevState = {}; - this.completionOrigin = ''; this.store = store; + this.prevMode = ''; let doc = this.wrapper.ownerDocument; let input = doc.querySelector('#vimvixen-console-command-input'); + input.addEventListener('blur', this.onBlur.bind(this)); input.addEventListener('keydown', this.onKeyDown.bind(this)); input.addEventListener('input', this.onInput.bind(this)); - this.hideCommand(); - this.hideMessage(); - store.subscribe(() => { this.update(); }); + this.update(); } onBlur() { - return browser.runtime.sendMessage({ - type: messages.CONSOLE_BLURRED, - }); + let state = this.store.getState(); + if (state.mode === 'command') { + this.hideCommand(); + } } onKeyDown(e) { - let doc = this.wrapper.ownerDocument; - let input = doc.querySelector('#vimvixen-console-command-input'); - switch (e.keyCode) { case KeyboardEvent.DOM_VK_ESCAPE: - return input.blur(); + return this.hideCommand(); case KeyboardEvent.DOM_VK_RETURN: - return browser.runtime.sendMessage({ - type: messages.CONSOLE_ENTERED, - text: e.target.value - }).then(this.onBlur); + e.stopPropagation(); + e.preventDefault(); + return this.onEntered(e.target.value); case KeyboardEvent.DOM_VK_TAB: if (e.shiftKey) { this.store.dispatch(consoleActions.completionPrev()); @@ -52,94 +51,105 @@ export default class ConsoleComponent { } } + onEntered(value) { + let state = this.store.getState(); + if (state.mode === 'command') { + browser.runtime.sendMessage({ + type: messages.CONSOLE_ENTER_COMMAND, + text: value, + }); + this.hideCommand(); + } else if (state.mode === 'find') { + this.hideCommand(); + window.top.postMessage(JSON.stringify({ + type: messages.CONSOLE_ENTER_FIND, + text: value, + }), '*'); + } + } + onInput(e) { - let doc = this.wrapper.ownerDocument; - let input = doc.querySelector('#vimvixen-console-command-input'); - this.completionOrigin = input.value; + this.store.dispatch(consoleActions.setConsoleText(e.target.value)); + let source = e.target.value; return browser.runtime.sendMessage({ type: messages.CONSOLE_QUERY_COMPLETIONS, - text: e.target.value, + text: source, }).then((completions) => { - this.store.dispatch(consoleActions.setCompletions(completions)); + this.store.dispatch(consoleActions.setCompletions(source, completions)); }); } - update() { - let state = this.store.getState(); - if (this.prevState.mode !== 'command' && state.mode === 'command') { - this.showCommand(state.commandText); - } else if (state.mode !== 'command') { - this.hideCommand(); - } + onInputShown(state) { + let doc = this.wrapper.ownerDocument; + let input = doc.querySelector('#vimvixen-console-command-input'); - if (state.mode === 'error' || state.mode === 'info') { - this.showMessage(state.mode, state.messageText); - } else { - this.hideMessage(); - } + input.focus(); + window.focus(); - if (state.groupSelection >= 0 && state.itemSelection >= 0) { - let group = state.completions[state.groupSelection]; - let item = group.items[state.itemSelection]; - this.setCommandValue(item.content); - } else if (state.completions.length > 0 && - JSON.stringify(this.prevState.completions) === - JSON.stringify(state.completions)) { - // Reset input only completion groups not changed (unselected an item in - // completion) in order to avoid to override previous input - this.setCommandCompletionOrigin(); + if (state.mode === 'command') { + this.onInput({ target: input }); } + } - this.prevState = state; + hideCommand() { + this.store.dispatch(consoleActions.hideCommand()); + window.top.postMessage(JSON.stringify({ + type: messages.CONSOLE_UNFOCUS, + }), '*'); } - showCommand(text) { - let doc = this.wrapper.ownerDocument; - let command = doc.querySelector('#vimvixen-console-command'); - let input = doc.querySelector('#vimvixen-console-command-input'); + update() { + let state = this.store.getState(); - command.style.display = 'block'; - input.value = text; - input.focus(); + this.updateMessage(state); + this.updateCommand(state); + this.updatePrompt(state); - window.focus(); - this.onInput({ target: input }); + if (this.prevMode !== state.mode && inputShownMode(state)) { + this.onInputShown(state); + } + this.prevMode = state.mode; } - hideCommand() { + updateMessage(state) { let doc = this.wrapper.ownerDocument; - let command = doc.querySelector('#vimvixen-console-command'); - command.style.display = 'none'; - } + let box = doc.querySelector('.vimvixen-console-message'); + let display = 'none'; + let classList = ['vimvixen-console-message']; - setCommandValue(value) { - let doc = this.wrapper.ownerDocument; - let input = doc.querySelector('#vimvixen-console-command-input'); - input.value = value; + if (state.mode === 'error' || state.mode === 'info') { + display = 'block'; + classList.push('vimvixen-console-' + state.mode); + } + + box.className = classList.join(' '); + box.style.display = display; + box.textContent = state.messageText; } - setCommandCompletionOrigin() { + updateCommand(state) { let doc = this.wrapper.ownerDocument; + let command = doc.querySelector('#vimvixen-console-command'); let input = doc.querySelector('#vimvixen-console-command-input'); - input.value = this.completionOrigin; - } - showMessage(mode, text) { - let doc = this.wrapper.ownerDocument; - let error = doc.querySelector('#vimvixen-console-message'); - error.classList.remove( - 'vimvixen-console-info', - 'vimvixen-console-error' - ); - error.classList.add('vimvixen-console-' + mode); - error.textContent = text; - error.style.display = 'block'; + let display = 'none'; + if (inputShownMode(state)) { + display = 'block'; + } + + command.style.display = display; + input.value = state.consoleText; } - hideMessage() { + updatePrompt(state) { + let classList = ['vimvixen-console-command-prompt']; + if (inputShownMode(state)) { + classList.push('prompt-' + state.mode); + } + let doc = this.wrapper.ownerDocument; - let error = doc.querySelector('#vimvixen-console-message'); - error.style.display = 'none'; + let ele = doc.querySelector('.vimvixen-console-command-prompt'); + ele.className = classList.join(' '); } } diff --git a/src/console/index.html b/src/console/index.html index f41a8dc..e049b5e 100644 --- a/src/console/index.html +++ b/src/console/index.html @@ -6,9 +6,8 @@ <script src='console.js'></script> </head> <body class='vimvixen-console'> - <p id='vimvixen-console-message' - class='vimvixen-console-message'></p> - <div id='vimvixen-console-command'> + <p class='vimvixen-console-message'></p> + <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 diff --git a/src/console/index.js b/src/console/index.js index 36473fe..86edd9a 100644 --- a/src/console/index.js +++ b/src/console/index.js @@ -18,12 +18,12 @@ const onMessage = (message) => { switch (message.type) { case messages.CONSOLE_SHOW_COMMAND: return store.dispatch(consoleActions.showCommand(message.command)); + case messages.CONSOLE_SHOW_FIND: + return store.dispatch(consoleActions.showFind()); case messages.CONSOLE_SHOW_ERROR: return store.dispatch(consoleActions.showError(message.text)); case messages.CONSOLE_SHOW_INFO: return store.dispatch(consoleActions.showInfo(message.text)); - case messages.CONSOLE_HIDE_COMMAND: - return store.dispatch(consoleActions.hideCommand()); } }; diff --git a/src/console/reducers/index.js b/src/console/reducers/index.js index d4affa7..60c0007 100644 --- a/src/console/reducers/index.js +++ b/src/console/reducers/index.js @@ -3,7 +3,8 @@ import actions from 'console/actions'; const defaultState = { mode: '', messageText: '', - commandText: '', + consoleText: '', + completionSource: '', completions: [], groupSelection: -1, itemSelection: -1, @@ -43,13 +44,25 @@ const prevSelection = (state) => { return [state.groupSelection, state.itemSelection - 1]; }; +const nextConsoleText = (completions, group, item, defaults) => { + if (group < 0 || item < 0) { + return defaults; + } + return completions[group].items[item].content; +}; + export default function reducer(state = defaultState, action = {}) { switch (action.type) { case actions.CONSOLE_SHOW_COMMAND: return Object.assign({}, state, { mode: 'command', - commandText: action.text, - errorShown: false, + consoleText: action.text, + completions: [] + }); + case actions.CONSOLE_SHOW_FIND: + return Object.assign({}, state, { + mode: 'find', + consoleText: '', completions: [] }); case actions.CONSOLE_SHOW_ERROR: @@ -64,11 +77,16 @@ export default function reducer(state = defaultState, action = {}) { }); case actions.CONSOLE_HIDE_COMMAND: return Object.assign({}, state, { - mode: state.mode === 'command' ? '' : state.mode, + mode: state.mode === 'command' || state.mode === 'find' ? '' : state.mode, + }); + case actions.CONSOLE_SET_CONSOLE_TEXT: + return Object.assign({}, state, { + consoleText: action.consoleText, }); case actions.CONSOLE_SET_COMPLETIONS: return Object.assign({}, state, { completions: action.completions, + completionSource: action.completionSource, groupSelection: -1, itemSelection: -1, }); @@ -77,6 +95,9 @@ export default function reducer(state = defaultState, action = {}) { return Object.assign({}, state, { groupSelection: next[0], itemSelection: next[1], + consoleText: nextConsoleText( + state.completions, next[0], next[1], + state.completionSource), }); } case actions.CONSOLE_COMPLETION_PREV: { @@ -84,6 +105,9 @@ export default function reducer(state = defaultState, action = {}) { return Object.assign({}, state, { groupSelection: next[0], itemSelection: next[1], + consoleText: nextConsoleText( + state.completions, next[0], next[1], + state.completionSource), }); } default: diff --git a/src/console/site.scss b/src/console/site.scss index e5cb2df..5aaea12 100644 --- a/src/console/site.scss +++ b/src/console/site.scss @@ -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; @@ -85,9 +88,15 @@ body { display: flex; &-prompt:before { + @include consoole-font; + } + + &-prompt.prompt-command:before { content: ':'; + } - @include consoole-font; + &-prompt.prompt-find:before { + content: '/'; } &-input { diff --git a/src/content/actions/find.js b/src/content/actions/find.js new file mode 100644 index 0000000..80d6210 --- /dev/null +++ b/src/content/actions/find.js @@ -0,0 +1,55 @@ +// +// window.find(aString, aCaseSensitive, aBackwards, aWrapAround, +// aWholeWord, aSearchInFrames); +// +// NOTE: window.find is not standard API +// https://developer.mozilla.org/en-US/docs/Web/API/Window/find + +import actions from 'content/actions'; +import * as consoleFrames from '../console-frames'; + +const postPatternNotFound = (pattern) => { + return consoleFrames.postError( + window.document, + 'Pattern not found: ' + pattern); +}; + +const find = (string, backwards) => { + let caseSensitive = false; + let wrapScan = true; + + + // NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work + // because of same origin policy + return window.find(string, caseSensitive, backwards, wrapScan); +}; + +const findNext = (keyword, reset, backwards) => { + if (reset) { + window.getSelection().removeAllRanges(); + } + + let found = find(keyword, backwards); + if (!found) { + window.getSelection().removeAllRanges(); + found = find(keyword, backwards); + } + if (!found) { + postPatternNotFound(keyword); + } + return { + type: actions.FIND_SET_KEYWORD, + keyword, + found, + }; +}; + +const next = (keyword, reset) => { + return findNext(keyword, reset, false); +}; + +const prev = (keyword, reset) => { + return findNext(keyword, reset, true); +}; + +export { next, prev }; diff --git a/src/content/actions/index.js b/src/content/actions/index.js index 8cc2303..7e32e12 100644 --- a/src/content/actions/index.js +++ b/src/content/actions/index.js @@ -21,4 +21,7 @@ export default { FOLLOW_CONTROLLER_DISABLE: 'follow.controller.disable', FOLLOW_CONTROLLER_KEY_PRESS: 'follow.controller.key.press', FOLLOW_CONTROLLER_BACKSPACE: 'follow.controller.backspace', + + // Find + FIND_SET_KEYWORD: 'find.set.keyword', }; diff --git a/src/content/actions/operation.js b/src/content/actions/operation.js index 897f361..767f14b 100644 --- a/src/content/actions/operation.js +++ b/src/content/actions/operation.js @@ -6,6 +6,7 @@ import * as urls from 'content/urls'; import * as consoleFrames from 'content/console-frames'; import * as addonActions from './addon'; +// eslint-disable-next-line complexity const exec = (operation) => { switch (operation.type) { case operations.ADDON_ENABLE: @@ -14,6 +15,14 @@ const exec = (operation) => { return addonActions.disable(); case operations.ADDON_TOGGLE_ENABLED: return addonActions.toggleEnabled(); + case operations.FIND_NEXT: + return window.top.postMessage(JSON.stringify({ + type: messages.FIND_NEXT, + }), '*'); + case operations.FIND_PREV: + return window.top.postMessage(JSON.stringify({ + type: messages.FIND_PREV, + }), '*'); case operations.SCROLL_VERTICALLY: return scrolls.scrollVertically(window, operation.count); case operations.SCROLL_HORIZONALLY: diff --git a/src/content/actions/setting.js b/src/content/actions/setting.js index c874294..0238c71 100644 --- a/src/content/actions/setting.js +++ b/src/content/actions/setting.js @@ -1,9 +1,22 @@ import actions from 'content/actions'; +import * as keyUtils from 'shared/utils/keys'; const set = (value) => { + let entries = []; + if (value.keymaps) { + entries = Object.entries(value.keymaps).map((entry) => { + return [ + keyUtils.fromMapKeys(entry[0]), + entry[1], + ]; + }); + } + return { type: actions.SETTING_SET, - value, + value: Object.assign({}, value, { + keymaps: entries, + }) }; }; diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js index 15b2a98..7717154 100644 --- a/src/content/components/common/follow.js +++ b/src/content/components/common/follow.js @@ -47,7 +47,7 @@ export default class Follow { } this.win.parent.postMessage(JSON.stringify({ type: messages.FOLLOW_KEY_PRESS, - key, + key: key.key, }), '*'); return true; } diff --git a/src/content/components/common/hint.css b/src/content/components/common/hint.css index 119dd21..1f2ab20 100644 --- a/src/content/components/common/hint.css +++ b/src/content/components/common/hint.css @@ -4,7 +4,7 @@ font-weight: bold; position: absolute; text-transform: uppercase; - z-index: 100000; + z-index: 2147483647; font-size: 12px; color: black; } diff --git a/src/content/components/common/input.js b/src/content/components/common/input.js index 8b1d35d..22b0a91 100644 --- a/src/content/components/common/input.js +++ b/src/content/components/common/input.js @@ -1,22 +1,5 @@ import * as dom from 'shared/utils/dom'; - -const modifierdKeyName = (name) => { - if (name.length === 1) { - return name.toUpperCase(); - } else if (name === 'Escape') { - return 'Esc'; - } - return name; -}; - -const mapKey = (e) => { - if (e.ctrlKey) { - return '<C-' + modifierdKeyName(e.key) + '>'; - } else if (e.shiftKey && e.key.length !== 1) { - return '<S-' + modifierdKeyName(e.key) + '>'; - } - return e.key; -}; +import * as keys from 'shared/utils/keys'; export default class InputComponent { constructor(target) { @@ -64,7 +47,7 @@ export default class InputComponent { return; } - let key = mapKey(e); + let key = keys.fromKeyboardEvent(e); for (let listener of this.onKeyListeners) { let stop = listener(key); diff --git a/src/content/components/common/keymapper.js b/src/content/components/common/keymapper.js index 1da3c0d..fb8fabe 100644 --- a/src/content/components/common/keymapper.js +++ b/src/content/components/common/keymapper.js @@ -1,6 +1,19 @@ import * as inputActions from 'content/actions/input'; import * as operationActions from 'content/actions/operation'; import operations from 'shared/operations'; +import * as keyUtils from 'shared/utils/keys'; + +const mapStartsWith = (mapping, keys) => { + if (mapping.length < keys.length) { + return false; + } + for (let i = 0; i < keys.length; ++i) { + if (!keyUtils.equals(mapping[i], keys[i])) { + return false; + } + } + return true; +}; export default class KeymapperComponent { constructor(store) { @@ -12,16 +25,16 @@ export default class KeymapperComponent { let state = this.store.getState(); let input = state.input; - let keymaps = state.setting.keymaps; + let keymaps = new Map(state.setting.keymaps); - let matched = Object.keys(keymaps).filter((keyStr) => { - return keyStr.startsWith(input.keys); + let matched = Array.from(keymaps.keys()).filter((mapping) => { + return mapStartsWith(mapping, input.keys); }); if (!state.addon.enabled) { // available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if // the addon disabled matched = matched.filter((keys) => { - let type = keymaps[keys].type; + let type = keymaps.get(keys).type; return type === operations.ADDON_ENABLE || type === operations.ADDON_TOGGLE_ENABLED; }); @@ -30,10 +43,10 @@ export default class KeymapperComponent { this.store.dispatch(inputActions.clearKeys()); return false; } else if (matched.length > 1 || - matched.length === 1 && input.keys !== matched[0]) { + matched.length === 1 && input.keys.length < matched[0].length) { return true; } - let operation = keymaps[matched]; + let operation = keymaps.get(matched[0]); this.store.dispatch(operationActions.exec(operation)); this.store.dispatch(inputActions.clearKeys()); return true; diff --git a/src/content/components/top-content/find.js b/src/content/components/top-content/find.js new file mode 100644 index 0000000..bccf040 --- /dev/null +++ b/src/content/components/top-content/find.js @@ -0,0 +1,54 @@ +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) { + this.win = win; + this.store = store; + + messages.onMessage(this.onMessage.bind(this)); + } + + onMessage(message) { + switch (message.type) { + case messages.CONSOLE_ENTER_FIND: + return this.start(message.text); + case messages.FIND_NEXT: + return this.next(); + case messages.FIND_PREV: + return this.prev(); + } + } + + start(text) { + let state = this.store.getState().find; + + if (text.length === 0) { + return this.store.dispatch(findActions.next(state.keyword, true)); + } + return this.store.dispatch(findActions.next(text, true)); + } + + 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)); + } +} diff --git a/src/content/components/top-content/follow-controller.js b/src/content/components/top-content/follow-controller.js index 38869e6..d373177 100644 --- a/src/content/components/top-content/follow-controller.js +++ b/src/content/components/top-content/follow-controller.js @@ -76,7 +76,7 @@ export default class FollowController { this.activate(); this.store.dispatch(followControllerActions.disable()); break; - case 'Escape': + case 'Esc': this.store.dispatch(followControllerActions.disable()); break; case 'Backspace': diff --git a/src/content/components/top-content/index.js b/src/content/components/top-content/index.js index f6afbfa..cf21ec4 100644 --- a/src/content/components/top-content/index.js +++ b/src/content/components/top-content/index.js @@ -1,5 +1,6 @@ import CommonComponent from '../common'; import FollowController from './follow-controller'; +import FindComponent from './find'; import * as consoleFrames from '../../console-frames'; import * as addonActions from '../../actions/addon'; import messages from 'shared/messages'; @@ -14,11 +15,14 @@ export default class TopContent { new CommonComponent(win, store); // eslint-disable-line no-new new FollowController(win, store); // eslint-disable-line no-new + new FindComponent(win, store); // eslint-disable-line no-new // TODO make component consoleFrames.initialize(this.win.document); messages.onMessage(this.onMessage.bind(this)); + + this.store.subscribe(() => this.update()); } update() { @@ -45,7 +49,7 @@ export default class TopContent { onMessage(message) { switch (message.type) { - case messages.CONSOLE_HIDE_COMMAND: + case messages.CONSOLE_UNFOCUS: this.win.focus(); consoleFrames.blur(window.document); return Promise.resolve(); diff --git a/src/content/console-frame.scss b/src/content/console-frame.scss index 33bfff3..dece648 100644 --- a/src/content/console-frame.scss +++ b/src/content/console-frame.scss @@ -6,7 +6,8 @@ width: 100%; height: 100%; position: fixed; - z-index: 10000; + z-index: 2147483647; border: none; + background-color: unset; pointer-events:none; } diff --git a/src/content/console-frames.js b/src/content/console-frames.js index 35b975f..515ae09 100644 --- a/src/content/console-frames.js +++ b/src/content/console-frames.js @@ -1,4 +1,5 @@ import './console-frame.scss'; +import messages from 'shared/messages'; const initialize = (doc) => { let iframe = doc.createElement('iframe'); @@ -20,4 +21,11 @@ const postMessage = (doc, message) => { iframe.contentWindow.postMessage(JSON.stringify(message), '*'); }; -export { initialize, blur, postMessage }; +const postError = (doc, message) => { + return postMessage(doc, { + type: messages.CONSOLE_SHOW_ERROR, + text: message, + }); +}; + +export { initialize, blur, postMessage, postError }; diff --git a/src/content/navigates.js b/src/content/navigates.js index 64e5fc0..3e12a6f 100644 --- a/src/content/navigates.js +++ b/src/content/navigates.js @@ -2,13 +2,14 @@ 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'); + const links = win.document.getElementsByTagName('a'); return Array.prototype.find.call(links, (link) => { return patterns.some(ptn => ptn.test(link.textContent)); }); @@ -22,30 +23,32 @@ const historyNext = (win) => { win.history.forward(); }; -const linkPrev = (win) => { - let link = win.document.querySelector('a[rel=prev]'); +const linkCommon = (win, rel, patterns) => { + let link = win.document.querySelector(`link[rel~=${rel}][href]`); + if (link) { - return link.click(); + win.location = link.getAttribute('href'); + return; } - link = findLinkByPatterns(win, PREV_LINK_PATTERNS); + + link = win.document.querySelector(`a[rel~=${rel}]`) || + findLinkByPatterns(win, patterns); + if (link) { link.click(); } }; +const linkPrev = (win) => { + linkCommon(win, 'prev', PREV_LINK_PATTERNS); +}; + 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(); - } + linkCommon(win, 'next', NEXT_LINK_PATTERNS); }; const parent = (win) => { - let loc = win.location; + const loc = win.location; if (loc.hash !== '') { loc.hash = ''; return; diff --git a/src/content/reducers/find.js b/src/content/reducers/find.js new file mode 100644 index 0000000..eb43c37 --- /dev/null +++ b/src/content/reducers/find.js @@ -0,0 +1,18 @@ +import actions from 'content/actions'; + +const defaultState = { + keyword: '', + found: false, +}; + +export default function reducer(state = defaultState, action = {}) { + switch (action.type) { + case actions.FIND_SET_KEYWORD: + return Object.assign({}, state, { + keyword: action.keyword, + found: action.found, + }); + default: + return state; + } +} diff --git a/src/content/reducers/index.js b/src/content/reducers/index.js index 17c0429..2487d85 100644 --- a/src/content/reducers/index.js +++ b/src/content/reducers/index.js @@ -1,4 +1,5 @@ import addonReducer from './addon'; +import findReducer from './find'; import settingReducer from './setting'; import inputReducer from './input'; import followControllerReducer from './follow-controller'; @@ -6,6 +7,7 @@ import followControllerReducer from './follow-controller'; // Make setting reducer instead of re-use const defaultState = { addon: addonReducer(undefined, {}), + find: findReducer(undefined, {}), setting: settingReducer(undefined, {}), input: inputReducer(undefined, {}), followController: followControllerReducer(undefined, {}), @@ -14,6 +16,7 @@ const defaultState = { export default function reducer(state = defaultState, action = {}) { return Object.assign({}, state, { addon: addonReducer(state.addon, action), + find: findReducer(state.find, action), setting: settingReducer(state.setting, action), input: inputReducer(state.input, action), followController: followControllerReducer(state.followController, action), diff --git a/src/content/reducers/input.js b/src/content/reducers/input.js index 9457604..134aa95 100644 --- a/src/content/reducers/input.js +++ b/src/content/reducers/input.js @@ -1,18 +1,18 @@ import actions from 'content/actions'; const defaultState = { - keys: '' + keys: [] }; export default function reducer(state = defaultState, action = {}) { switch (action.type) { case actions.INPUT_KEY_PRESS: return Object.assign({}, state, { - keys: state.keys + action.key + keys: state.keys.concat([action.key]), }); case actions.INPUT_CLEAR_KEYS: return Object.assign({}, state, { - keys: '', + keys: [], }); default: return state; diff --git a/src/content/reducers/setting.js b/src/content/reducers/setting.js index b6f6c58..a23027f 100644 --- a/src/content/reducers/setting.js +++ b/src/content/reducers/setting.js @@ -1,7 +1,8 @@ import actions from 'content/actions'; const defaultState = { - keymaps: {}, + // keymaps is and arrays of key-binding pairs, which is entries of Map + keymaps: [], }; export default function reducer(state = defaultState, action = {}) { diff --git a/src/content/scrolls.js b/src/content/scrolls.js index d88320f..ef38273 100644 --- a/src/content/scrolls.js +++ b/src/content/scrolls.js @@ -104,14 +104,14 @@ const scrollBottom = (win) => { const scrollHome = (win) => { let target = scrollTarget(win); let x = 0; - let y = target.scrollLeft; + let y = target.scrollTop; target.scrollTo(x, y); }; const scrollEnd = (win) => { let target = scrollTarget(win); let x = target.scrollWidth; - let y = target.scrollLeft; + let y = target.scrollTop; target.scrollTo(x, y); }; diff --git a/src/shared/default-settings.js b/src/shared/default-settings.js index 60e7876..608890b 100644 --- a/src/shared/default-settings.js +++ b/src/shared/default-settings.js @@ -28,8 +28,12 @@ export default { "u": { "type": "tabs.reopen" }, "K": { "type": "tabs.prev", "count": 1 }, "J": { "type": "tabs.next", "count": 1 }, + "g0": { "type": "tabs.first" }, + "g$": { "type": "tabs.last" }, "r": { "type": "tabs.reload", "cache": false }, "R": { "type": "tabs.reload", "cache": true }, + "zp": { "type": "tabs.pin.toggle" }, + "zd": { "type": "tabs.duplicate" }, "zi": { "type": "zoom.in" }, "zo": { "type": "zoom.out" }, "zz": { "type": "zoom.neutral" }, @@ -42,6 +46,9 @@ export default { "gu": { "type": "navigate.parent" }, "gU": { "type": "navigate.root" }, "y": { "type": "urls.yank" }, + "/": { "type": "find.start" }, + "n": { "type": "find.next" }, + "N": { "type": "find.prev" }, "<S-Esc>": { "type": "addon.toggle.enabled" } }, "search": { diff --git a/src/shared/messages.js b/src/shared/messages.js index dc497b6..de00a3f 100644 --- a/src/shared/messages.js +++ b/src/shared/messages.js @@ -24,13 +24,14 @@ const onMessage = (listener) => { export default { BACKGROUND_OPERATION: 'background.operation', - CONSOLE_BLURRED: 'console.blured', - CONSOLE_ENTERED: 'console.entered', + CONSOLE_UNFOCUS: 'console.unfocus', + CONSOLE_ENTER_COMMAND: 'console.enter.command', + CONSOLE_ENTER_FIND: 'console.enter.find', CONSOLE_QUERY_COMPLETIONS: 'console.query.completions', CONSOLE_SHOW_COMMAND: 'console.show.command', CONSOLE_SHOW_ERROR: 'console.show.error', CONSOLE_SHOW_INFO: 'console.show.info', - CONSOLE_HIDE_COMMAND: 'console.hide.command', + CONSOLE_SHOW_FIND: 'console.show.find', FOLLOW_START: 'follow.start', FOLLOW_REQUEST_COUNT_TARGETS: 'follow.request.count.targets', @@ -41,6 +42,9 @@ export default { FOLLOW_ACTIVATE: 'follow.activate', FOLLOW_KEY_PRESS: 'follow.key.press', + FIND_NEXT: 'find.next', + FIND_PREV: 'find.prev', + OPEN_URL: 'open.url', SETTINGS_RELOAD: 'settings.reload', diff --git a/src/shared/operations.js b/src/shared/operations.js index d5c2985..4c221ba 100644 --- a/src/shared/operations.js +++ b/src/shared/operations.js @@ -36,7 +36,13 @@ export default { TAB_REOPEN: 'tabs.reopen', TAB_PREV: 'tabs.prev', TAB_NEXT: 'tabs.next', + TAB_FIRST: 'tabs.first', + TAB_LAST: 'tabs.last', TAB_RELOAD: 'tabs.reload', + TAB_PIN: 'tabs.pin', + TAB_UNPIN: 'tabs.unpin', + TAB_TOGGLE_PINNED: 'tabs.pin.toggle', + TAB_DUPLICATE: 'tabs.duplicate', // Zooms ZOOM_IN: 'zoom.in', @@ -45,4 +51,9 @@ export default { // Url yank URLS_YANK: 'urls.yank', + + // Find + FIND_START: 'find.start', + FIND_NEXT: 'find.next', + FIND_PREV: 'find.prev', }; diff --git a/src/shared/utils/keys.js b/src/shared/utils/keys.js new file mode 100644 index 0000000..fba8ce3 --- /dev/null +++ b/src/shared/utils/keys.js @@ -0,0 +1,86 @@ +const modifierdKeyName = (name) => { + if (name.length === 1) { + return name; + } else if (name === 'Escape') { + return 'Esc'; + } + return name; +}; + +const fromKeyboardEvent = (e) => { + let key = modifierdKeyName(e.key); + let shift = e.shiftKey; + if (key.length === 1 && key.toUpperCase() === key.toLowerCase()) { + // make shift false for symbols to enable key bindings by symbold keys. + // But this limits key bindings by symbol keys with Shift (such as Shift+$>. + shift = false; + } + + return { + key: modifierdKeyName(e.key), + shiftKey: shift, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + metaKey: e.metaKey, + }; +}; + +const fromMapKey = (key) => { + if (key.startsWith('<') && key.endsWith('>')) { + let inner = key.slice(1, -1); + let shift = inner.includes('S-'); + let base = inner.slice(inner.lastIndexOf('-') + 1); + if (shift && base.length === 1) { + base = base.toUpperCase(); + } else if (!shift && base.length === 1) { + base = base.toLowerCase(); + } + return { + key: base, + shiftKey: inner.includes('S-'), + ctrlKey: inner.includes('C-'), + altKey: inner.includes('A-'), + metaKey: inner.includes('M-'), + }; + } + return { + key: key, + shiftKey: key.toLowerCase() !== key, + ctrlKey: false, + altKey: false, + metaKey: false, + }; +}; + +const fromMapKeys = (keys) => { + const fromMapKeysRecursive = (remainings, mappedKeys) => { + if (remainings.length === 0) { + return mappedKeys; + } + + let nextPos = 1; + if (remainings.startsWith('<')) { + let ltPos = remainings.indexOf('>'); + if (ltPos > 0) { + nextPos = ltPos + 1; + } + } + + return fromMapKeysRecursive( + remainings.slice(nextPos), + mappedKeys.concat([fromMapKey(remainings.slice(0, nextPos))]) + ); + }; + + return fromMapKeysRecursive(keys, []); +}; + +const equals = (e1, e2) => { + return e1.key === e2.key && + e1.ctrlKey === e2.ctrlKey && + e1.metaKey === e2.metaKey && + e1.altKey === e2.altKey && + e1.shiftKey === e2.shiftKey; +}; + +export { fromKeyboardEvent, fromMapKey, fromMapKeys, equals }; diff --git a/test/console/actions/console.test.js b/test/console/actions/console.test.js index 3b02d4a..9af13d4 100644 --- a/test/console/actions/console.test.js +++ b/test/console/actions/console.test.js @@ -11,6 +11,13 @@ describe("console actions", () => { }); }); + describe("showFind", () => { + it('create CONSOLE_SHOW_FIND action', () => { + let action = consoleActions.showFind(); + expect(action.type).to.equal(actions.CONSOLE_SHOW_FIND); + }); + }); + describe("showInfo", () => { it('create CONSOLE_SHOW_INFO action', () => { let action = consoleActions.showInfo('an info'); @@ -27,17 +34,26 @@ describe("console actions", () => { }); }); - describe("hide", () => { + describe("hideCommand", () => { it('create CONSOLE_HIDE_COMMAND action', () => { let action = consoleActions.hideCommand(); expect(action.type).to.equal(actions.CONSOLE_HIDE_COMMAND); }); }); + describe('setConsoleText', () => { + it('create CONSOLE_SET_CONSOLE_TEXT action', () => { + let action = consoleActions.setConsoleText('hello world'); + expect(action.type).to.equal(actions.CONSOLE_SET_CONSOLE_TEXT); + expect(action.consoleText).to.equal('hello world'); + }); + }); + describe("setCompletions", () => { it('create CONSOLE_SET_COMPLETIONS action', () => { - let action = consoleActions.setCompletions([1,2,3]); + let action = consoleActions.setCompletions('query', [1, 2, 3]); expect(action.type).to.equal(actions.CONSOLE_SET_COMPLETIONS); + expect(action.completionSource).to.deep.equal('query'); expect(action.completions).to.deep.equal([1, 2, 3]); }); }); @@ -56,4 +72,3 @@ describe("console actions", () => { }); }); }); - diff --git a/test/console/reducers/console.test.js b/test/console/reducers/console.test.js index 4f85e55..438d513 100644 --- a/test/console/reducers/console.test.js +++ b/test/console/reducers/console.test.js @@ -7,7 +7,7 @@ describe("console reducer", () => { let state = reducer(undefined, {}); expect(state).to.have.property('mode', ''); expect(state).to.have.property('messageText', ''); - expect(state).to.have.property('commandText', ''); + expect(state).to.have.property('consoleText', ''); expect(state).to.have.deep.property('completions', []); expect(state).to.have.property('groupSelection', -1); expect(state).to.have.property('itemSelection', -1); @@ -17,7 +17,7 @@ describe("console reducer", () => { let action = { type: actions.CONSOLE_SHOW_COMMAND, text: 'open ' }; let state = reducer({}, action); expect(state).to.have.property('mode', 'command'); - expect(state).to.have.property('commandText', 'open '); + expect(state).to.have.property('consoleText', 'open '); }); it('return next state for CONSOLE_SHOW_INFO', () => { @@ -43,6 +43,16 @@ describe("console reducer", () => { expect(state).to.have.property('mode', 'error'); }); + it('return next state for CONSOLE_SET_CONSOLE_TEXT', () => { + let action = { + type: actions.CONSOLE_SET_CONSOLE_TEXT, + consoleText: 'hello world' + } + let state = reducer({}, action) + + expect(state).to.have.property('consoleText', 'hello world'); + }); + it ('return next state for CONSOLE_SET_COMPLETIONS', () => { let state = { groupSelection: 0, diff --git a/test/content/actions/setting.test.js b/test/content/actions/setting.test.js index 8855f04..1248edf 100644 --- a/test/content/actions/setting.test.js +++ b/test/content/actions/setting.test.js @@ -7,7 +7,28 @@ describe("setting actions", () => { it('create SETTING_SET action', () => { let action = settingActions.set({ red: 'apple', yellow: 'banana' }); expect(action.type).to.equal(actions.SETTING_SET); - expect(action.value).to.deep.equal({ red: 'apple', yellow: 'banana' }); + expect(action.value.red).to.equal('apple'); + expect(action.value.yellow).to.equal('banana'); + expect(action.value.keymaps).to.be.empty; + }); + + it('converts keymaps', () => { + let action = settingActions.set({ + keymaps: { + 'dd': 'remove current tab', + 'z<C-A>': 'increment', + } + }); + let keymaps = action.value.keymaps; + let map = new Map(keymaps); + expect(map).to.have.deep.all.keys( + [ + [{ key: 'd', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }, + { key: 'd', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }], + [{ key: 'z', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }, + { key: 'a', shiftKey: false, ctrlKey: true, altKey: false, metaKey: false }], + ] + ); }); }); }); diff --git a/test/content/components/common/input.test.js b/test/content/components/common/input.test.js index 912ac34..a346cf6 100644 --- a/test/content/components/common/input.test.js +++ b/test/content/components/common/input.test.js @@ -4,20 +4,21 @@ import { expect } from "chai"; describe('InputComponent', () => { it('register callbacks', () => { let component = new InputComponent(window.document); + let key = { key: 'a', ctrlKey: true, shiftKey: false, altKey: false, metaKey: false }; component.onKey((key) => { - expect(key).is.equals('a'); + expect(key).to.deep.equal(key); }); - component.onKeyDown({ key: 'a' }); + component.onKeyDown(key); }); it('invoke callback once', () => { let component = new InputComponent(window.document); let a = 0, b = 0; component.onKey((key) => { - if (key == 'a') { + if (key.key == 'a') { ++a; } else { - key == 'b' + key.key == 'b' ++b; } }); @@ -32,38 +33,6 @@ describe('InputComponent', () => { expect(b).is.equals(1); }) - it('add prefix when ctrl pressed', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals('<C-A>'); - }); - component.onKeyDown({ key: 'a', ctrlKey: true }); - }) - - it('press X', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals('X'); - }); - component.onKeyDown({ key: 'X', shiftKey: true }); - }) - - it('press <Shift> + <Esc>', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals('<S-Esc>'); - }); - component.onKeyDown({ key: 'Escape', shiftKey: true }); - }) - - it('press <Ctrl> + <Esc>', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals('<C-Esc>'); - }); - component.onKeyDown({ key: 'Escape', ctrlKey: true }); - }) - it('does not invoke only meta keys', () => { let component = new InputComponent(window.document); component.onKey((key) => { diff --git a/test/content/navigates.test.js b/test/content/navigates.test.js index b5144e9..d8a3316 100644 --- a/test/content/navigates.test.js +++ b/test/content/navigates.test.js @@ -1,56 +1,138 @@ -import { expect } from "chai"; +import { expect } from 'chai'; import * as navigates from 'content/navigates'; +const testRel = (done, rel, html) => { + const method = rel === 'prev' ? 'linkPrev' : 'linkNext'; + document.body.innerHTML = html; + navigates[method](window); + setTimeout(() => { + expect(document.location.hash).to.equal(`#${rel}`); + done(); + }, 0); +}; + +const testPrev = html => done => testRel(done, 'prev', html); +const testNext = html => done => testRel(done, 'next', html); + describe('navigates module', () => { describe('#linkPrev', () => { - it('clicks prev link by text content', (done) => { - document.body.innerHTML = '<a href="#dummy">xprevx</a> <a href="#prev">go to prev</a>'; - navigates.linkPrev(window); - setTimeout(() => { - expect(document.location.hash).to.equal('#prev'); - done(); - }, 0); - }); + it('navigates to <link> elements whose rel attribute is "prev"', testPrev( + '<link rel="prev" href="#prev" />' + )); - it('clicks a[rel=prev] element preferentially', (done) => { - document.body.innerHTML = '<a href="#dummy">prev</a> <a rel="prev" href="#prev">rel</a>'; - navigates.linkPrev(window); - setTimeout(() => { - expect(document.location.hash).to.equal('#prev'); - done(); - }, 0); - }); - }); + it('navigates to <link> elements whose rel attribute starts with "prev"', testPrev( + '<link rel="prev bar" href="#prev" />' + )); + + it('navigates to <link> elements whose rel attribute ends with "prev"', testPrev( + '<link rel="foo prev" href="#prev" />' + )); + + it('navigates to <link> elements whose rel attribute contains "prev"', testPrev( + '<link rel="foo prev bar" href="#prev" />' + )); + + it('navigates to <a> elements whose rel attribute is "prev"', testPrev( + '<a rel="prev" href="#prev">click me</a>' + )); + + it('navigates to <a> elements whose rel attribute starts with "prev"', testPrev( + '<a rel="prev bar" href="#prev">click me</a>' + )); + + it('navigates to <a> elements whose rel attribute ends with "prev"', testPrev( + '<a rel="foo prev" href="#prev">click me</a>' + )); + + it('navigates to <a> elements whose rel attribute contains "prev"', testPrev( + '<a rel="foo prev bar" href="#prev">click me</a>' + )); + + it('navigates to <a> elements whose text matches "prev"', testPrev( + '<a href="#dummy">preview</a><a href="#prev">go to prev</a>' + )); + + it('navigates to <a> elements whose text matches "previous"', testPrev( + '<a href="#dummy">preview</a><a href="#prev">go to previous</a>' + )); + + it('navigates to <a> elements whose decoded text matches "<<"', testPrev( + '<a href="#dummy">click me</a><a href="#prev"><<</a>' + )); + + it('navigates to matching <a> elements by clicking', testPrev( + `<a rel="prev" href="#dummy" onclick="return location = '#prev', false">go to prev</a>` + )); + + it('prefers link[rel~=prev] to a[rel~=prev]', testPrev( + '<a rel="prev" href="#dummy">click me</a><link rel="prev" href="#prev" />' + )); + it('prefers a[rel~=prev] to a::text(pattern)', testPrev( + '<a href="#dummy">go to prev</a><a rel="prev" href="#prev">click me</a>' + )); + }); describe('#linkNext', () => { - it('clicks next link by text content', (done) => { - document.body.innerHTML = '<a href="#dummy">xnextx</a> <a href="#next">go to next</a>'; - navigates.linkNext(window); - setTimeout(() => { - expect(document.location.hash).to.equal('#next'); - done(); - }, 0); - }); + it('navigates to <link> elements whose rel attribute is "next"', testNext( + '<link rel="next" href="#next" />' + )); - it('clicks a[rel=next] element preferentially', (done) => { - document.body.innerHTML = '<a href="#dummy">next</a> <a rel="next" href="#next">rel</a>'; - navigates.linkNext(window); - setTimeout(() => { - expect(document.location.hash).to.equal('#next'); - done(); - }, 0); - }); + it('navigates to <link> elements whose rel attribute starts with "next"', testNext( + '<link rel="next bar" href="#next" />' + )); + + it('navigates to <link> elements whose rel attribute ends with "next"', testNext( + '<link rel="foo next" href="#next" />' + )); + + it('navigates to <link> elements whose rel attribute contains "next"', testNext( + '<link rel="foo next bar" href="#next" />' + )); + + it('navigates to <a> elements whose rel attribute is "next"', testNext( + '<a rel="next" href="#next">click me</a>' + )); + + it('navigates to <a> elements whose rel attribute starts with "next"', testNext( + '<a rel="next bar" href="#next">click me</a>' + )); + + it('navigates to <a> elements whose rel attribute ends with "next"', testNext( + '<a rel="foo next" href="#next">click me</a>' + )); + + it('navigates to <a> elements whose rel attribute contains "next"', testNext( + '<a rel="foo next bar" href="#next">click me</a>' + )); + + it('navigates to <a> elements whose text matches "next"', testNext( + '<a href="#dummy">inextricable</a><a href="#next">go to next</a>' + )); + + it('navigates to <a> elements whose decoded text matches ">>"', testNext( + '<a href="#dummy">click me</a><a href="#next">>></a>' + )); + + it('navigates to matching <a> elements by clicking', testNext( + `<a rel="next" href="#dummy" onclick="return location = '#next', false">go to next</a>` + )); + + it('prefers link[rel~=next] to a[rel~=next]', testNext( + '<a rel="next" href="#dummy">click me<><link rel="next" href="#next" />' + )); + + it('prefers a[rel~=next] to a::text(pattern)', testNext( + '<a href="#dummy">go to next</a><a rel="next" href="#next">click me</a>' + )); }); describe('#parent', () => { // NOTE: not able to test location it('removes hash', () => { - window.location.hash = "#section-1"; + window.location.hash = '#section-1'; navigates.parent(window); expect(document.location.hash).to.be.empty; }); }); }); - - diff --git a/test/content/reducers/find.test.js b/test/content/reducers/find.test.js new file mode 100644 index 0000000..93625da --- /dev/null +++ b/test/content/reducers/find.test.js @@ -0,0 +1,23 @@ +import { expect } from "chai"; +import actions from 'content/actions'; +import findReducer from 'content/reducers/find'; + +describe("find reducer", () => { + it('return the initial state', () => { + let state = findReducer(undefined, {}); + expect(state).to.have.property('keyword', ''); + expect(state).to.have.property('found', false); + }); + + it('return next state for FIND_SET_KEYWORD', () => { + let action = { + type: actions.FIND_SET_KEYWORD, + keyword: 'xyz', + found: true, + }; + let state = findReducer({}, action); + + expect(state.keyword).is.equal('xyz'); + expect(state.found).to.be.true; + }); +}); diff --git a/test/content/reducers/input.test.js b/test/content/reducers/input.test.js index d5e5f6b..d0b5655 100644 --- a/test/content/reducers/input.test.js +++ b/test/content/reducers/input.test.js @@ -5,22 +5,22 @@ import inputReducer from 'content/reducers/input'; describe("input reducer", () => { it('return the initial state', () => { let state = inputReducer(undefined, {}); - expect(state).to.have.deep.property('keys', ''); + expect(state).to.have.deep.property('keys', []); }); it('return next state for INPUT_KEY_PRESS', () => { let action = { type: actions.INPUT_KEY_PRESS, key: 'a' }; let state = inputReducer(undefined, action); - expect(state).to.have.deep.property('keys', 'a'); + expect(state).to.have.deep.property('keys', ['a']); - action = { type: actions.INPUT_KEY_PRESS, key: '<C-B>' }; + action = { type: actions.INPUT_KEY_PRESS, key: 'b' }; state = inputReducer(state, action); - expect(state).to.have.deep.property('keys', 'a<C-B>'); + expect(state).to.have.deep.property('keys', ['a', 'b']); }); it('return next state for INPUT_CLEAR_KEYS', () => { let action = { type: actions.INPUT_CLEAR_KEYS }; - let state = inputReducer({ keys: 'abc' }, action); - expect(state).to.have.deep.property('keys', ''); + let state = inputReducer({ keys: [1, 2, 3] }, action); + expect(state).to.have.deep.property('keys', []); }); }); diff --git a/test/content/reducers/setting.test.js b/test/content/reducers/setting.test.js index ef49594..634b299 100644 --- a/test/content/reducers/setting.test.js +++ b/test/content/reducers/setting.test.js @@ -5,7 +5,7 @@ import settingReducer from 'content/reducers/setting'; describe("content setting reducer", () => { it('return the initial state', () => { let state = settingReducer(undefined, {}); - expect(state).to.deep.equal({ keymaps: {} }); + expect(state.keymaps).to.be.empty; }); it('return next state for SETTING_SET', () => { diff --git a/test/shared/utils/keys.test.js b/test/shared/utils/keys.test.js new file mode 100644 index 0000000..5ca8b54 --- /dev/null +++ b/test/shared/utils/keys.test.js @@ -0,0 +1,145 @@ +import { expect } from 'chai'; +import * as keys from 'shared/utils/keys'; + +describe("keys util", () => { + describe('fromKeyboardEvent', () => { + it('returns from keyboard input Ctrl+X', () => { + let k = keys.fromKeyboardEvent({ + key: 'x', shiftKey: false, ctrlKey: true, altKey: false, metaKey: true + }); + expect(k.key).to.equal('x'); + expect(k.shiftKey).to.be.false; + expect(k.ctrlKey).to.be.true; + expect(k.altKey).to.be.false; + expect(k.metaKey).to.be.true; + }); + + it('returns from keyboard input Shift+Esc', () => { + let k = keys.fromKeyboardEvent({ + key: 'Escape', shiftKey: true, ctrlKey: false, altKey: false, metaKey: true + }); + expect(k.key).to.equal('Esc'); + expect(k.shiftKey).to.be.true; + expect(k.ctrlKey).to.be.false; + expect(k.altKey).to.be.false; + expect(k.metaKey).to.be.true; + }); + + it('returns from keyboard input Ctrl+$', () => { + // $ required shift pressing on most keyboards + let k = keys.fromKeyboardEvent({ + key: '$', shiftKey: true, ctrlKey: true, altKey: false, metaKey: false + }); + expect(k.key).to.equal('$'); + expect(k.shiftKey).to.be.false; + expect(k.ctrlKey).to.be.true; + expect(k.altKey).to.be.false; + expect(k.metaKey).to.be.false; + }); + }); + + describe('fromMapKey', () => { + it('return for X', () => { + let key = keys.fromMapKey('x'); + expect(key.key).to.equal('x'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('return for Shift+X', () => { + let key = keys.fromMapKey('X'); + expect(key.key).to.equal('X'); + expect(key.shiftKey).to.be.true; + expect(key.ctrlKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('return for Ctrl+X', () => { + let key = keys.fromMapKey('<C-X>'); + expect(key.key).to.equal('x'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('returns for Ctrl+Meta+X', () => { + let key = keys.fromMapKey('<C-M-X>'); + expect(key.key).to.equal('x'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.true; + }); + + it('returns for Ctrl+Shift+x', () => { + let key = keys.fromMapKey('<C-S-x>'); + expect(key.key).to.equal('X'); + expect(key.shiftKey).to.be.true; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('returns for Shift+Esc', () => { + let key = keys.fromMapKey('<S-Esc>'); + expect(key.key).to.equal('Esc'); + expect(key.shiftKey).to.be.true; + expect(key.ctrlKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('returns for Ctrl+Esc', () => { + let key = keys.fromMapKey('<C-Esc>'); + expect(key.key).to.equal('Esc'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + }); + + describe('fromMapKeys', () => { + it('returns mapped keys for Shift+Esc', () => { + let keyArray = keys.fromMapKeys('<S-Esc>'); + expect(keyArray).to.have.lengthOf(1); + expect(keyArray[0].key).to.equal('Esc'); + expect(keyArray[0].shiftKey).to.be.true; + }); + + it('returns mapped keys for a<C-B><A-C>d<M-e>', () => { + let keyArray = keys.fromMapKeys('a<C-B><A-C>d<M-e>'); + expect(keyArray).to.have.lengthOf(5); + expect(keyArray[0].key).to.equal('a'); + expect(keyArray[1].ctrlKey).to.be.true; + expect(keyArray[1].key).to.equal('b'); + expect(keyArray[2].altKey).to.be.true; + expect(keyArray[2].key).to.equal('c'); + expect(keyArray[3].key).to.equal('d'); + expect(keyArray[4].metaKey).to.be.true; + expect(keyArray[4].key).to.equal('e'); + }); + }) + + describe('equals', () => { + expect(keys.equals({ + key: 'x', + ctrlKey: true, + }, { + key: 'x', + ctrlKey: true, + })).to.be.true; + + expect(keys.equals({ + key: 'X', + shiftKey: true, + }, { + key: 'x', + ctrlKey: true, + })).to.be.false; + }); +}); diff --git a/test/shared/util/re.test.js b/test/shared/utils/re.test.js index 9ed6521..9ed6521 100644 --- a/test/shared/util/re.test.js +++ b/test/shared/utils/re.test.js |