diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2018-05-01 13:51:07 +0900 |
---|---|---|
committer | Shin'ya Ueoka <ueokande@i-beam.org> | 2018-05-01 13:51:07 +0900 |
commit | 4d7c24f38a6861e1d498b6e7dd5c7be2d1a0ad15 (patch) | |
tree | 0948f730a25eeda127c56a0f01009f14d066e4dc /src | |
parent | 177940981ed9c4f096ad7db20f0b7ee044fd7b17 (diff) | |
parent | fb8a0f36aa4d070df936cc7598ef8dd988ee1b15 (diff) |
Merge remote-tracking branch 'origin/master' into background-adjacent-tabs
Diffstat (limited to 'src')
69 files changed, 1690 insertions, 409 deletions
diff --git a/src/background/actions/command.js b/src/background/actions/command.js new file mode 100644 index 0000000..4c52bca --- /dev/null +++ b/src/background/actions/command.js @@ -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 }; diff --git a/src/background/actions/find.js b/src/background/actions/find.js new file mode 100644 index 0000000..8da5572 --- /dev/null +++ b/src/background/actions/find.js @@ -0,0 +1,10 @@ +import actions from './index'; + +const setKeyword = (keyword) => { + return { + type: actions.FIND_SET_KEYWORD, + keyword, + }; +}; + +export { setKeyword }; diff --git a/src/background/actions/index.js b/src/background/actions/index.js new file mode 100644 index 0000000..2bdaaf2 --- /dev/null +++ b/src/background/actions/index.js @@ -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', +}; diff --git a/src/background/actions/operation.js b/src/background/actions/operation.js index 1e4990c..56eb168 100644 --- a/src/background/actions/operation.js +++ b/src/background/actions/operation.js @@ -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(); } diff --git a/src/background/actions/setting.js b/src/background/actions/setting.js new file mode 100644 index 0000000..773142f --- /dev/null +++ b/src/background/actions/setting.js @@ -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 }; diff --git a/src/background/actions/tab.js b/src/background/actions/tab.js index d996b9f..3c642fd 100644 --- a/src/background/actions/tab.js +++ b/src/background/actions/tab.js @@ -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 }; diff --git a/src/background/components/background.js b/src/background/components/background.js index 6026684..fae3fbb 100644 --- a/src/background/components/background.js +++ b/src/background/components/background.js @@ -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({}); } } diff --git a/src/background/histories.js b/src/background/histories.js index 6b6e4c6..a7d3d47 100644 --- a/src/background/histories.js +++ b/src/background/histories.js @@ -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]; }); }; diff --git a/src/background/index.js b/src/background/index.js index 8a68767..3ef712f 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -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()); diff --git a/src/background/reducers/find.js b/src/background/reducers/find.js new file mode 100644 index 0000000..4ded801 --- /dev/null +++ b/src/background/reducers/find.js @@ -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; + } +} diff --git a/src/background/reducers/index.js b/src/background/reducers/index.js index 4be8fac..63ff0f8 100644 --- a/src/background/reducers/index.js +++ b/src/background/reducers/index.js @@ -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), }); } diff --git a/src/background/reducers/setting.js b/src/background/reducers/setting.js new file mode 100644 index 0000000..045a654 --- /dev/null +++ b/src/background/reducers/setting.js @@ -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; + } +} + diff --git a/src/background/tabs.js b/src/background/tabs.js index d641616..e939870 100644 --- a/src/background/tabs.js +++ b/src/background/tabs.js @@ -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 }; diff --git a/src/console/actions/console.js b/src/console/actions/console.js index 2cf8e8d..f80045f 100644 --- a/src/console/actions/console.js +++ b/src/console/actions/console.js @@ -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 }; diff --git a/src/console/actions/index.js b/src/console/actions/index.js index a85e329..b394179 100644 --- a/src/console/actions/index.js +++ b/src/console/actions/index.js @@ -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', diff --git a/src/console/components/console.js b/src/console/components/console.js index 7bc3364..a9ae4ed 100644 --- a/src/console/components/console.js +++ b/src/console/components/console.js @@ -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; } } diff --git a/src/console/index.html b/src/console/index.html index 52ecb76..e049b5e 100644 --- a/src/console/index.html +++ b/src/console/index.html @@ -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 diff --git a/src/console/index.js b/src/console/index.js index 86edd9a..156456c 100644 --- a/src/console/index.js +++ b/src/console/index.js @@ -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()); } }; diff --git a/src/console/reducers/index.js b/src/console/reducers/index.js index 60c0007..2aec55c 100644 --- a/src/console/reducers/index.js +++ b/src/console/reducers/index.js @@ -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', diff --git a/src/console/site.scss b/src/console/site.scss index cd40db5..cb89e50 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; @@ -66,6 +69,8 @@ body { &-message { @include consoole-font; + + border-top: 1px solid gray; } &-error { diff --git a/src/content/actions/find.js b/src/content/actions/find.js index 80d6210..c7345cc 100644 --- a/src/content/actions/find.js +++ b/src/content/actions/find.js @@ -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 }; diff --git a/src/content/actions/operation.js b/src/content/actions/operation.js index 5c8fe83..9171766 100644 --- a/src/content/actions/operation.js +++ b/src/content/actions/operation.js @@ -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, diff --git a/src/content/actions/setting.js b/src/content/actions/setting.js index 0238c71..4c1e385 100644 --- a/src/content/actions/setting.js +++ b/src/content/actions/setting.js @@ -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], diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js index 43f2ea1..2a55ea3 100644 --- a/src/content/components/common/follow.js +++ b/src/content/components/common/follow.js @@ -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; 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 22b0a91..eefaf10 100644 --- a/src/content/components/common/input.js +++ b/src/content/components/common/input.js @@ -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; diff --git a/src/content/components/common/keymapper.js b/src/content/components/common/keymapper.js index fb8fabe..d9d1b2f 100644 --- a/src/content/components/common/keymapper.js +++ b/src/content/components/common/keymapper.js @@ -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; } diff --git a/src/content/components/top-content/find.js b/src/content/components/top-content/find.js index bccf040..4d46d79 100644 --- a/src/content/components/top-content/find.js +++ b/src/content/components/top-content/find.js @@ -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)); } } diff --git a/src/content/components/top-content/follow-controller.js b/src/content/components/top-content/follow-controller.js index f759c8c..7f36604 100644 --- a/src/content/components/top-content/follow-controller.js +++ b/src/content/components/top-content/follow-controller.js @@ -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; + } } 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 515ae09..0c0ec02 100644 --- a/src/content/console-frames.js +++ b/src/content/console-frames.js @@ -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 }; diff --git a/src/content/focuses.js b/src/content/focuses.js new file mode 100644 index 0000000..a6f6cc8 --- /dev/null +++ b/src/content/focuses.js @@ -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 }; diff --git a/src/content/navigates.js b/src/content/navigates.js index 64e5fc0..c9baa30 100644 --- a/src/content/navigates.js +++ b/src/content/navigates.js @@ -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; diff --git a/src/content/reducers/find.js b/src/content/reducers/find.js index eb43c37..8d63ee5 100644 --- a/src/content/reducers/find.js +++ b/src/content/reducers/find.js @@ -1,7 +1,7 @@ import actions from 'content/actions'; const defaultState = { - keyword: '', + keyword: null, found: false, }; diff --git a/src/content/scrolls.js b/src/content/scrolls.js index ef38273..0d1f7c8 100644 --- a/src/content/scrolls.js +++ b/src/content/scrolls.js @@ -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 { diff --git a/src/content/urls.js b/src/content/urls.js index 8f8a1ac..9b7b284 100644 --- a/src/content/urls.js +++ b/src/content/urls.js @@ -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 }; diff --git a/src/settings/actions/setting.js b/src/settings/actions/setting.js index c1b27c8..92c9f8a 100644 --- a/src/settings/actions/setting.js +++ b/src/settings/actions/setting.js @@ -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, }; }; diff --git a/src/settings/components/form/blacklist-form.jsx b/src/settings/components/form/blacklist-form.jsx new file mode 100644 index 0000000..7ae9652 --- /dev/null +++ b/src/settings/components/form/blacklist-form.jsx @@ -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; diff --git a/src/settings/components/form/blacklist-form.scss b/src/settings/components/form/blacklist-form.scss new file mode 100644 index 0000000..a230d0d --- /dev/null +++ b/src/settings/components/form/blacklist-form.scss @@ -0,0 +1,9 @@ +.form-blacklist-form { + &-row { + display: flex; + + .column-url { + flex: 1; + } + } +} diff --git a/src/settings/components/form/keymaps-form.jsx b/src/settings/components/form/keymaps-form.jsx new file mode 100644 index 0000000..0e4a223 --- /dev/null +++ b/src/settings/components/form/keymaps-form.jsx @@ -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; diff --git a/src/settings/components/form/keymaps-form.scss b/src/settings/components/form/keymaps-form.scss new file mode 100644 index 0000000..1a4e5cd --- /dev/null +++ b/src/settings/components/form/keymaps-form.scss @@ -0,0 +1,11 @@ +.form-keymaps-form { + column-count: 3; + + &-field-group { + margin-top: 24px; + } + + &-field-group:first-of-type { + margin-top: 24px; + } +} diff --git a/src/settings/components/form/properties-form.jsx b/src/settings/components/form/properties-form.jsx new file mode 100644 index 0000000..55c8512 --- /dev/null +++ b/src/settings/components/form/properties-form.jsx @@ -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; diff --git a/src/settings/components/form/properties-form.scss b/src/settings/components/form/properties-form.scss new file mode 100644 index 0000000..7c9e167 --- /dev/null +++ b/src/settings/components/form/properties-form.scss @@ -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; + } + } +} diff --git a/src/settings/components/form/search-form.jsx b/src/settings/components/form/search-form.jsx new file mode 100644 index 0000000..e85761f --- /dev/null +++ b/src/settings/components/form/search-form.jsx @@ -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; diff --git a/src/settings/components/form/search-form.scss b/src/settings/components/form/search-form.scss new file mode 100644 index 0000000..26b2f44 --- /dev/null +++ b/src/settings/components/form/search-form.scss @@ -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; + } +} diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx index 4418942..e96fac3 100644 --- a/src/settings/components/index.jsx +++ b/src/settings/components/index.jsx @@ -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; diff --git a/src/settings/components/site.scss b/src/settings/components/site.scss index fae9c39..c0c4f9e 100644 --- a/src/settings/components/site.scss +++ b/src/settings/components/site.scss @@ -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; + } + } } diff --git a/src/settings/components/ui/add-button.jsx b/src/settings/components/ui/add-button.jsx new file mode 100644 index 0000000..79292d8 --- /dev/null +++ b/src/settings/components/ui/add-button.jsx @@ -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='✚' + {...this.props} />; + } +} + +export default AddButton; diff --git a/src/settings/components/ui/add-button.scss b/src/settings/components/ui/add-button.scss new file mode 100644 index 0000000..beb5688 --- /dev/null +++ b/src/settings/components/ui/add-button.scss @@ -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; + } +} diff --git a/src/settings/components/ui/delete-button.jsx b/src/settings/components/ui/delete-button.jsx new file mode 100644 index 0000000..8077a76 --- /dev/null +++ b/src/settings/components/ui/delete-button.jsx @@ -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='✖' + {...this.props} />; + } +} + +export default DeleteButton; diff --git a/src/settings/components/ui/delete-button.scss b/src/settings/components/ui/delete-button.scss new file mode 100644 index 0000000..5932a72 --- /dev/null +++ b/src/settings/components/ui/delete-button.scss @@ -0,0 +1,13 @@ + +.ui-delete-button { + border: none; + padding: 4; + display: inline; + background: none; + color: red; + cursor: pointer; + + &:hover { + color: darkred; + } +} diff --git a/src/settings/components/ui/input.jsx b/src/settings/components/ui/input.jsx new file mode 100644 index 0000000..e99dbc7 --- /dev/null +++ b/src/settings/components/ui/input.jsx @@ -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; diff --git a/src/settings/components/ui/input.scss b/src/settings/components/ui/input.scss new file mode 100644 index 0000000..ad4daf8 --- /dev/null +++ b/src/settings/components/ui/input.scss @@ -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; + } +} diff --git a/src/settings/index.jsx b/src/settings/index.jsx index 7516fb7..eb251b4 100644 --- a/src/settings/index.jsx +++ b/src/settings/index.jsx @@ -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>, diff --git a/src/settings/reducers/setting.js b/src/settings/reducers/setting.js index a61c09f..70c6183 100644 --- a/src/settings/reducers/setting.js +++ b/src/settings/reducers/setting.js @@ -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: diff --git a/src/shared/commands.js b/src/shared/commands.js deleted file mode 100644 index bcad313..0000000 --- a/src/shared/commands.js +++ /dev/null @@ -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 }; diff --git a/src/shared/commands/complete.js b/src/shared/commands/complete.js new file mode 100644 index 0000000..0bdbab8 --- /dev/null +++ b/src/shared/commands/complete.js @@ -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; diff --git a/src/shared/commands/index.js b/src/shared/commands/index.js new file mode 100644 index 0000000..78cb4df --- /dev/null +++ b/src/shared/commands/index.js @@ -0,0 +1,3 @@ +import complete from './complete'; + +export { complete }; diff --git a/src/shared/commands/parsers.js b/src/shared/commands/parsers.js new file mode 100644 index 0000000..fb37d2a --- /dev/null +++ b/src/shared/commands/parsers.js @@ -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 }; diff --git a/src/shared/messages.js b/src/shared/messages.js index de00a3f..a404658 100644 --- a/src/shared/messages.js +++ b/src/shared/messages.js @@ -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', diff --git a/src/shared/operations.js b/src/shared/operations.js index 4c221ba..a2f980f 100644 --- a/src/shared/operations.js +++ b/src/shared/operations.js @@ -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', diff --git a/src/shared/default-settings.js b/src/shared/settings/default.js index d691859..7de5b51 100644 --- a/src/shared/default-settings.js +++ b/src/shared/settings/default.js @@ -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": { + } +}`, }; diff --git a/src/shared/settings/properties.js b/src/shared/settings/properties.js new file mode 100644 index 0000000..7093f2b --- /dev/null +++ b/src/shared/settings/properties.js @@ -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 }; diff --git a/src/shared/settings/storage.js b/src/shared/settings/storage.js new file mode 100644 index 0000000..9b8045d --- /dev/null +++ b/src/shared/settings/storage.js @@ -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 }; diff --git a/src/shared/validators/setting.js b/src/shared/settings/validator.js index 5fe75b2..1589420 100644 --- a/src/shared/validators/setting.js +++ b/src/shared/settings/validator.js @@ -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 }; diff --git a/src/shared/settings/values.js b/src/shared/settings/values.js new file mode 100644 index 0000000..bd03be2 --- /dev/null +++ b/src/shared/settings/values.js @@ -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 +}; diff --git a/src/shared/store/provider.jsx b/src/shared/store/provider.jsx index 743f656..fe925aa 100644 --- a/src/shared/store/provider.jsx +++ b/src/shared/store/provider.jsx @@ -1,18 +1,15 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import { h, Component } from 'preact'; -class Provider extends React.PureComponent { +class Provider extends Component { getChildContext() { return { store: this.props.store }; } render() { - return React.Children.only(this.props.children); + return <div> + { this.props.children } + </div>; } } -Provider.childContextTypes = { - store: PropTypes.any, -}; - export default Provider; diff --git a/src/shared/utils/dom.js b/src/shared/utils/dom.js index d4fd68a..974d534 100644 --- a/src/shared/utils/dom.js +++ b/src/shared/utils/dom.js @@ -81,4 +81,28 @@ const viewportRect = (e) => { }; }; -export { isContentEditable, viewportRect }; +const isVisible = (element) => { + let rect = element.getBoundingClientRect(); + let style = window.getComputedStyle(element); + + if (style.overflow !== 'visible' && (rect.width === 0 || rect.height === 0)) { + return false; + } + if (rect.right < 0 && rect.bottom < 0) { + return false; + } + if (window.innerWidth < rect.left && window.innerHeight < rect.top) { + return false; + } + if (element.nodeName === 'INPUT' && element.type.toLowerCase() === 'hidden') { + return false; + } + + let { display, visibility } = window.getComputedStyle(element); + if (display === 'none' || visibility === 'hidden') { + return false; + } + return true; +}; + +export { isContentEditable, viewportRect, isVisible }; diff --git a/src/shared/utils/keys.js b/src/shared/utils/keys.js index fba8ce3..8a86dfb 100644 --- a/src/shared/utils/keys.js +++ b/src/shared/utils/keys.js @@ -18,6 +18,7 @@ const fromKeyboardEvent = (e) => { return { key: modifierdKeyName(e.key), + repeat: e.repeat, shiftKey: shift, ctrlKey: e.ctrlKey, altKey: e.altKey, |