aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2018-05-01 13:51:07 +0900
committerShin'ya Ueoka <ueokande@i-beam.org>2018-05-01 13:51:07 +0900
commit4d7c24f38a6861e1d498b6e7dd5c7be2d1a0ad15 (patch)
tree0948f730a25eeda127c56a0f01009f14d066e4dc /src
parent177940981ed9c4f096ad7db20f0b7ee044fd7b17 (diff)
parentfb8a0f36aa4d070df936cc7598ef8dd988ee1b15 (diff)
Merge remote-tracking branch 'origin/master' into background-adjacent-tabs
Diffstat (limited to 'src')
-rw-r--r--src/background/actions/command.js79
-rw-r--r--src/background/actions/find.js10
-rw-r--r--src/background/actions/index.js8
-rw-r--r--src/background/actions/operation.js8
-rw-r--r--src/background/actions/setting.js21
-rw-r--r--src/background/actions/tab.js18
-rw-r--r--src/background/components/background.js31
-rw-r--r--src/background/histories.js1
-rw-r--r--src/background/index.js4
-rw-r--r--src/background/reducers/find.js16
-rw-r--r--src/background/reducers/index.js5
-rw-r--r--src/background/reducers/setting.js24
-rw-r--r--src/background/tabs.js31
-rw-r--r--src/console/actions/console.js8
-rw-r--r--src/console/actions/index.js1
-rw-r--r--src/console/components/console.js45
-rw-r--r--src/console/index.html2
-rw-r--r--src/console/index.js2
-rw-r--r--src/console/reducers/index.js4
-rw-r--r--src/console/site.scss7
-rw-r--r--src/content/actions/find.js59
-rw-r--r--src/content/actions/operation.js29
-rw-r--r--src/content/actions/setting.js9
-rw-r--r--src/content/components/common/follow.js20
-rw-r--r--src/content/components/common/hint.css2
-rw-r--r--src/content/components/common/input.js6
-rw-r--r--src/content/components/common/keymapper.js3
-rw-r--r--src/content/components/top-content/find.js13
-rw-r--r--src/content/components/top-content/follow-controller.js20
-rw-r--r--src/content/console-frame.scss3
-rw-r--r--src/content/console-frames.js9
-rw-r--r--src/content/focuses.js13
-rw-r--r--src/content/navigates.js62
-rw-r--r--src/content/reducers/find.js2
-rw-r--r--src/content/scrolls.js177
-rw-r--r--src/content/urls.js26
-rw-r--r--src/settings/actions/setting.js31
-rw-r--r--src/settings/components/form/blacklist-form.jsx52
-rw-r--r--src/settings/components/form/blacklist-form.scss9
-rw-r--r--src/settings/components/form/keymaps-form.jsx109
-rw-r--r--src/settings/components/form/keymaps-form.scss11
-rw-r--r--src/settings/components/form/properties-form.jsx60
-rw-r--r--src/settings/components/form/properties-form.scss12
-rw-r--r--src/settings/components/form/search-form.jsx78
-rw-r--r--src/settings/components/form/search-form.scss28
-rw-r--r--src/settings/components/index.jsx202
-rw-r--r--src/settings/components/site.scss19
-rw-r--r--src/settings/components/ui/add-button.jsx12
-rw-r--r--src/settings/components/ui/add-button.scss13
-rw-r--r--src/settings/components/ui/delete-button.jsx12
-rw-r--r--src/settings/components/ui/delete-button.scss13
-rw-r--r--src/settings/components/ui/input.jsx52
-rw-r--r--src/settings/components/ui/input.scss29
-rw-r--r--src/settings/index.jsx5
-rw-r--r--src/settings/reducers/setting.js2
-rw-r--r--src/shared/commands.js181
-rw-r--r--src/shared/commands/complete.js84
-rw-r--r--src/shared/commands/index.js3
-rw-r--r--src/shared/commands/parsers.js59
-rw-r--r--src/shared/messages.js3
-rw-r--r--src/shared/operations.js11
-rw-r--r--src/shared/settings/default.js (renamed from src/shared/default-settings.js)12
-rw-r--r--src/shared/settings/properties.js18
-rw-r--r--src/shared/settings/storage.js36
-rw-r--r--src/shared/settings/validator.js (renamed from src/shared/validators/setting.js)17
-rw-r--r--src/shared/settings/values.js108
-rw-r--r--src/shared/store/provider.jsx13
-rw-r--r--src/shared/utils/dom.js26
-rw-r--r--src/shared/utils/keys.js1
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='&#x271a;'
+ {...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='&#x2716;'
+ {...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,