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

This commit is contained in:
Shin'ya Ueoka 2018-05-01 15:51:40 +09:00
commit 9da2f5fd78
119 changed files with 14640 additions and 1632 deletions

View file

@ -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 };

View file

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

View file

@ -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',
};

View file

@ -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();
}

View file

@ -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 };

View file

@ -1,9 +1,21 @@
const openNewTab = (url) => {
return browser.tabs.create({ url: url });
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, openNewTab };
export { openNewTab, openToTab };

View file

@ -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,25 +33,31 @@ export default class BackgroundComponent {
sender);
case messages.OPEN_URL:
if (message.newTab) {
return this.store.dispatch(
tabActions.openNewTab(message.url), 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({});
}
}

View file

@ -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];
});
};

View file

@ -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());

View file

@ -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;
}
}

View file

@ -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),
});
}

View file

@ -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;
}
}

View file

@ -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
};

View file

@ -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
};

View file

@ -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',

View file

@ -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;
}
}

View file

@ -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());
}
};

View file

@ -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',

View file

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

View file

@ -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 };

View file

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

View file

@ -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,23 +28,24 @@ 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,
newTab: operation.newTab
newTab: operation.newTab,
background: operation.background,
}), '*');
case operations.NAVIGATE_HISTORY_PREV:
return navigates.historyPrev(window);
@ -54,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,

View file

@ -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],

View file

@ -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,11 +30,27 @@ 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;
this.store = store;
this.newTab = false;
this.background = false;
this.hints = {};
this.targets = [];
@ -47,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;
}
@ -68,6 +86,7 @@ export default class Follow {
type: messages.OPEN_URL,
url: element.href,
newTab: true,
background: this.background,
});
}
@ -79,12 +98,13 @@ export default class Follow {
}), '*');
}
createHints(keysArray, newTab) {
createHints(keysArray, newTab, background) {
if (keysArray.length !== this.targets.length) {
throw new Error('illegal hint count');
}
this.newTab = newTab;
this.background = background;
this.hints = {};
for (let i = 0; i < keysArray.length; ++i) {
let keys = keysArray[i];
@ -150,7 +170,8 @@ export default class Follow {
case messages.FOLLOW_REQUEST_COUNT_TARGETS:
return this.countHints(sender, message.viewSize, message.framePosition);
case messages.FOLLOW_CREATE_HINTS:
return this.createHints(message.keysArray, message.newTab);
return this.createHints(
message.keysArray, message.newTab, message.background);
case messages.FOLLOW_SHOW_HINTS:
return this.showHints(message.keys);
case messages.FOLLOW_ACTIVATE:
@ -170,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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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));
}
}

View file

@ -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);
@ -29,11 +28,11 @@ export default class FollowController {
switch (message.type) {
case messages.FOLLOW_START:
return this.store.dispatch(
followControllerActions.enable(message.newTab));
followControllerActions.enable(message.newTab, message.background));
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;
@ -126,6 +129,7 @@ export default class FollowController {
type: messages.FOLLOW_CREATE_HINTS,
keysArray: produced,
newTab: this.state.newTab,
background: this.state.background,
}), '*');
}
@ -135,4 +139,9 @@ export default class FollowController {
type: messages.FOLLOW_REMOVE_HINTS,
});
}
hintchars() {
return this.store.getState().setting.properties.hintchars ||
properties.defaults.hintchars;
}
}

View file

@ -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 };

13
src/content/focuses.js Normal file
View file

@ -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 };

View file

@ -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 REL_PATTERN = {
prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i,
next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i,
};
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));
});
// 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;

View file

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

View file

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

View file

@ -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;
}
// dirty way to store scrolling state on globally
let scrolling = [false];
let { display, visibility } = win.getComputedStyle(element);
if (display === 'none' || visibility === 'hidden') {
return false;
}
return true;
};
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 {

View file

@ -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 };

View file

@ -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,
};
};

View file

@ -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;

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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'>
<p>Load settings from:</p>
<input type='radio' id='setting-source-json'
<Input
type='radio'
id='setting-source-form'
name='source'
value='json'
onChange={this.bindAndSave.bind(this)}
checked={this.state.settings.source === 'json'} />
<label htmlFor='settings-source-json'>JSON</label>
label='Use form'
checked={this.state.settings.source === 'form'}
value='form'
onChange={this.bindSource.bind(this)} />
<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} />
<Input
type='radio'
name='source'
label='Use plain JSON'
checked={this.state.settings.source === 'json'}
value='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('');
} catch (err) {
e.target.setCustomValidity(err.message);
}
}
bindValue(e) {
let nextSettings = Object.assign({}, this.state.settings);
nextSettings[e.target.name] = e.target.value;
validateValue(e) {
let next = Object.assign({}, this.state);
this.setState({ settings: nextSettings });
next.errors.json = '';
try {
this.validate(e.target);
} catch (err) {
next.errors.json = err.message;
}
next.settings[e.target.name] = e.target.value;
}
bindAndSave(e) {
this.bindValue(e);
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 next = Object.assign({}, this.state);
let error = false;
next.errors.json = '';
try {
let json = this.state.settings.json;
validator.validate(JSON.parse(json));
this.context.store.dispatch(settingActions.save(this.state.settings));
this.validate(e.target);
} catch (err) {
// error already shown
next.errors.json = err.message;
error = true;
}
next.settings[e.target.name] = e.target.value;
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 {
validator.validate(JSON.parse(this.state.settings.json));
} catch (err) {
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));
}
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();
}
}
}
SettingsComponent.contextTypes = {
store: PropTypes.any,
};
export default SettingsComponent;

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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>,

View file

@ -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:

View file

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

View file

@ -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;

View file

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

View file

@ -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 };

View file

@ -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',

View file

@ -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',

View file

@ -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,6 +23,7 @@ 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 },
@ -32,6 +31,7 @@ export default {
"gt": { "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" },
@ -39,15 +39,18 @@ export default {
"zi": { "type": "zoom.in" },
"zo": { "type": "zoom.out" },
"zz": { "type": "zoom.neutral" },
"f": { "type": "follow.start", "newTab": false },
"F": { "type": "follow.start", "newTab": true },
"f": { "type": "follow.start", "newTab": false, "background": false },
"F": { "type": "follow.start", "newTab": true, "background": false },
"H": { "type": "navigate.history.prev" },
"L": { "type": "navigate.history.next" },
"[[": { "type": "navigate.link.prev" },
"]]": { "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" },
@ -63,6 +66,8 @@ export default {
"twitter": "https://twitter.com/search?q={}",
"wikipedia": "https://en.wikipedia.org/w/index.php?search={}"
}
},
"properties": {
}
}`
}`,
};

View file

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

View file

@ -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 };

View file

@ -1,6 +1,7 @@
import operations from 'shared/operations';
import * as properties from './properties';
const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist'];
const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist', 'properties'];
const VALID_OPERATION_VALUES = Object.keys(operations).map((key) => {
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 };

View file

@ -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
};

View file

@ -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;

View file

@ -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 };

View file

@ -18,6 +18,7 @@ const fromKeyboardEvent = (e) => {
return {
key: modifierdKeyName(e.key),
repeat: e.repeat,
shiftKey: shift,
ctrlKey: e.ctrlKey,
altKey: e.altKey,