diff options
34 files changed, 903 insertions, 272 deletions
diff --git a/package-lock.json b/package-lock.json index 7e88990..0d5ffd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -199,6 +199,12 @@ } } }, + "adm-zip": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.7.tgz", + "integrity": "sha1-hgbCy/HEJs6MjsABdER/1Jtur8E=", + "dev": true + }, "after": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", diff --git a/package.json b/package.json index 47ec787..05e3ff5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "start": "webpack -w --debug --devtool inline-source-map", "build": "NODE_ENV=production webpack --progress --display-error-details", - "package": "npm run build && ./package.sh", + "package": "npm run build && node script/package.js", "lint": "eslint --ext .jsx,.js src", "test": "karma start" }, @@ -19,6 +19,7 @@ }, "homepage": "https://github.com/ueokande/vim-vixen", "devDependencies": { + "adm-zip": "^0.4.7", "babel-cli": "^6.24.1", "babel-eslint": "^8.0.2", "babel-loader": "^7.1.1", diff --git a/package.sh b/package.sh deleted file mode 100755 index 34535ba..0000000 --- a/package.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh - -MANIFEST="manifest.json" - -version=$(jq -r '.version' $MANIFEST) - -icons=$(jq -r '.icons[]' $MANIFEST) -content_scripts=$(jq -r '.content_scripts[].js[]' $MANIFEST) -background_scripts=$(jq -r '.background.scripts[]' $MANIFEST) -web_accessible_resources=$(jq -r '.web_accessible_resources[]' $MANIFEST) -options_ui=$(jq -r '.options_ui.page' $MANIFEST) -options_scripts="" -for html in $options_ui; do - scripts=$(grep -Po "(?<=src=['\"])[^'\"]*" "$html") - for js in $scripts; do - options_scripts="$options_scripts $(dirname $html)/$js" - done -done - -zip ${version}.zip $MANIFEST $icons $content_scripts $background_scripts $web_accessible_resources $options_ui $options_scripts diff --git a/script/package.js b/script/package.js new file mode 100644 index 0000000..0128f0f --- /dev/null +++ b/script/package.js @@ -0,0 +1,55 @@ +let path = require('path'); +let fs = require('fs'); +let AdmZip = require('adm-zip'); +let manifest = require('../manifest'); + +manifest.iconFiles = function() { + return Object.keys(this.icons).map(key => this.icons[key]); +}; + +manifest.contentScriptFiles = function() { + let files = this.content_scripts.map(entry => entry.js); + return [].concat.apply([], files); +}; + +manifest.backgroundScriptFiles = function() { + return this.background.scripts; + +}; + +manifest.webAccessibleResourceFiles = function() { + return this.web_accessible_resources; +}; + +manifest.optionFiles = function() { + let uiFile = this.options_ui.page; + let dir = path.dirname(uiFile); + let html = fs.readFileSync(uiFile, 'utf-8'); + + let files = [uiFile]; + let regex = /<\s*script\s+src\s*=\s*'(.*)'\s*>/g; + let match = regex.exec(html); + while (match) { + files.push(path.join(dir, match[1])); + match = regex.exec(html); + } + return files; +}; + +let files = [] + .concat('manifest.json') + .concat(manifest.iconFiles()) + .concat(manifest.contentScriptFiles()) + .concat(manifest.backgroundScriptFiles()) + .concat(manifest.webAccessibleResourceFiles()) + .concat(manifest.optionFiles()); +let zip = new AdmZip(); +let output = `${manifest.version}.zip`; +console.log(output); +for (let f of files) { + let dir = path.dirname(f); + zip.addLocalFile(f, dir); + console.log('=>', path.join(dir, f)); +} + +zip.writeZip(output); 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/index.js b/src/background/actions/index.js new file mode 100644 index 0000000..efe4074 --- /dev/null +++ b/src/background/actions/index.js @@ -0,0 +1,5 @@ +export default { + // Settings + SETTING_SET_SETTINGS: 'setting.set.settings', + SETTING_SET_PROPERTY: 'setting.set.property', +}; 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/components/background.js b/src/background/components/background.js index 2d94310..9578e78 100644 --- a/src/background/components/background.js +++ b/src/background/components/background.js @@ -1,6 +1,7 @@ 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 tabActions from 'background/actions/tab'; import * as commands from 'shared/commands'; @@ -35,18 +36,17 @@ export default class BackgroundComponent { 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(); } } 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/index.js b/src/background/reducers/index.js index 4be8fac..dab0c62 100644 --- a/src/background/reducers/index.js +++ b/src/background/reducers/index.js @@ -1,4 +1,4 @@ -import settingReducer from 'settings/reducers/setting'; +import settingReducer from './setting'; // Make setting reducer instead of re-use const defaultState = { 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/content/actions/operation.js b/src/content/actions/operation.js index 0a5cd97..b4b2e38 100644 --- a/src/content/actions/operation.js +++ b/src/content/actions/operation.js @@ -5,9 +5,12 @@ import * as navigates from 'content/navigates'; 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 +27,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, 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/follow-controller.js b/src/content/components/top-content/follow-controller.js index d373177..1e7f3cd 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); @@ -84,7 +83,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 +92,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; @@ -135,4 +134,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/scrolls.js b/src/content/scrolls.js index ef38273..e8e9642 100644 --- a/src/content/scrolls.js +++ b/src/content/scrolls.js @@ -1,7 +1,11 @@ const SCROLL_DELTA_X = 48; const SCROLL_DELTA_Y = 48; +const SMOOTH_SCROLL_DURATION = 150; -const isVisible = (win, element) => { +// dirty way to store scrolling state on globally +let scrolling = [false]; + +const isVisible = (element) => { let rect = element.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { return false; @@ -9,19 +13,19 @@ const isVisible = (win, element) => { if (rect.right < 0 && rect.bottom < 0) { return false; } - if (win.innerWidth < rect.left && win.innerHeight < rect.top) { + if (window.innerWidth < rect.left && window.innerHeight < rect.top) { return false; } - let { display, visibility } = win.getComputedStyle(element); + let { display, visibility } = window.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 +39,15 @@ 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)); + .filter.call(element.children, e => isVisible(e)); for (let child of children) { - let scrollable = findScrollable(win, child); + let scrollable = findScrollable(child); if (scrollable) { return scrollable; } @@ -51,68 +55,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/settings/actions/setting.js b/src/settings/actions/setting.js index 1d01fda..92c9f8a 100644 --- a/src/settings/actions/setting.js +++ b/src/settings/actions/setting.js @@ -1,26 +1,22 @@ import actions from 'settings/actions'; import messages from 'shared/messages'; 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(DefaultSettings); - } - return set(Object.assign({}, DefaultSettings, settings)); - }, 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); }); }; 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/index.jsx b/src/settings/components/index.jsx index 73520ca..d7696a1 100644 --- a/src/settings/components/index.jsx +++ b/src/settings/components/index.jsx @@ -4,8 +4,10 @@ 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'; const DO_YOU_WANT_TO_CONTINUE = @@ -65,6 +67,14 @@ class SettingsComponent extends Component { 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>; } diff --git a/src/shared/commands.js b/src/shared/commands.js deleted file mode 100644 index ed64a63..0000000 --- a/src/shared/commands.js +++ /dev/null @@ -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 = 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 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 }; 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/settings/default.js b/src/shared/settings/default.js index 6b71717..0c3f0d9 100644 --- a/src/shared/settings/default.js +++ b/src/shared/settings/default.js @@ -60,6 +60,8 @@ export default { "duckduckgo": "https://duckduckgo.com/?q={}", "twitter": "https://twitter.com/search?q={}", "wikipedia": "https://en.wikipedia.org/w/index.php?search={}" + }, + "properties": { } } }`, diff --git a/src/shared/settings/properties.js b/src/shared/settings/properties.js new file mode 100644 index 0000000..37dc881 --- /dev/null +++ b/src/shared/settings/properties.js @@ -0,0 +1,16 @@ +// describe types of a propety as: +// mystr: 'string', +// mynum: 'number', +// mybool: 'boolean', +const types = { + hintchars: 'string', + smoothscroll: 'boolean', +}; + +// describe default values of a property +const defaults = { + hintchars: 'abcdefghijklmnopqrstuvwxyz', + smoothscroll: false, +}; + +export { types, defaults }; diff --git a/src/shared/settings/storage.js b/src/shared/settings/storage.js new file mode 100644 index 0000000..1edb441 --- /dev/null +++ b/src/shared/settings/storage.js @@ -0,0 +1,31 @@ +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); + } + return 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 949ab29..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']; +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 index 4e55fa0..bd03be2 100644 --- a/src/shared/settings/values.js +++ b/src/shared/settings/values.js @@ -1,3 +1,5 @@ +import * as properties from './properties'; + const operationFromFormName = (name) => { let [type, argStr] = name.split('?'); let args = {}; @@ -44,9 +46,12 @@ const valueFromForm = (form) => { } } - let blacklist = form.blacklist; - - return { keymaps, search, blacklist }; + return { + keymaps, + search, + blacklist: form.blacklist, + properties: form.properties + }; }; const jsonFromValue = (value) => { @@ -78,9 +83,14 @@ const formFromValue = (value, allowedOps) => { } } - let blacklist = value.blacklist; + let formProperties = Object.assign({}, properties.defaults, value.properties); - return { keymaps, search, blacklist }; + return { + keymaps, + search, + blacklist: value.blacklist, + properties: formProperties, + }; }; const jsonFromForm = (form) => { 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, diff --git a/test/background/reducers/setting.test.js b/test/background/reducers/setting.test.js new file mode 100644 index 0000000..2ef98cb --- /dev/null +++ b/test/background/reducers/setting.test.js @@ -0,0 +1,37 @@ +import { expect } from "chai"; +import actions from 'background/actions'; +import settingReducer from 'background/reducers/setting'; + +describe("setting reducer", () => { + it('return the initial state', () => { + let state = settingReducer(undefined, {}); + expect(state).to.have.deep.property('value', {}); + }); + + it('return next state for SETTING_SET_SETTINGS', () => { + let action = { + type: actions.SETTING_SET_SETTINGS, + value: { key: 123 }, + }; + let state = settingReducer(undefined, action); + expect(state).to.have.deep.property('value', { key: 123 }); + }); + + it('return next state for SETTING_SET_PROPERTY', () => { + let state = { + value: { + properties: { smoothscroll: true } + } + } + let action = { + type: actions.SETTING_SET_PROPERTY, + name: 'encoding', + value: 'utf-8', + }; + state = settingReducer(state, action); + + console.log(state); + expect(state.value.properties).to.have.property('smoothscroll', true); + expect(state.value.properties).to.have.property('encoding', 'utf-8'); + }); +}); diff --git a/test/settings/components/form/properties-form.test.jsx b/test/settings/components/form/properties-form.test.jsx new file mode 100644 index 0000000..4807361 --- /dev/null +++ b/test/settings/components/form/properties-form.test.jsx @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import { h, render } from 'preact'; +import PropertiesForm from 'settings/components/form/properties-form' + +describe("settings/form/PropertiesForm", () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('render', () => { + it('renders PropertiesForm', () => { + let types = { + mystr: 'string', + mynum: 'number', + mybool: 'boolean', + empty: 'string', + } + let value = { + mystr: 'abc', + mynum: 123, + mybool: true, + }; + render(<PropertiesForm types={types} value={value} />, document.body); + + let strInput = document.querySelector('input[name=mystr]'); + let numInput = document.querySelector('input[name=mynum]'); + let boolInput = document.querySelector('input[name=mybool]'); + let emptyInput = document.querySelector('input[name=empty]'); + + expect(strInput.type).to.equals('text'); + expect(strInput.value).to.equal('abc'); + expect(numInput.type).to.equals('number'); + expect(numInput.value).to.equal('123'); + expect(boolInput.type).to.equals('checkbox'); + expect(boolInput.checked).to.be.true; + expect(emptyInput.type).to.equals('text'); + expect(emptyInput.value).to.be.empty; + }); + }); + + describe('onChange', () => { + it('invokes onChange event on text changed', (done) => { + render(<PropertiesForm + types={{ 'myvalue': 'string' }} + value={{ 'myvalue': 'abc' }} + onChange={value => { + expect(value).to.have.property('myvalue', 'abcd'); + done(); + }} + />, document.body); + + let input = document.querySelector('input[name=myvalue]'); + input.value = 'abcd' + input.dispatchEvent(new Event('change')) + }); + + it('invokes onChange event on number changeed', (done) => { + render(<PropertiesForm + types={{ 'myvalue': 'number' }} + value={{ '': 123 }} + onChange={value => { + expect(value).to.have.property('myvalue', 1234); + done(); + }} + />, document.body); + + let input = document.querySelector('input[name=myvalue]'); + input.value = '1234' + input.dispatchEvent(new Event('change')) + }); + + it('invokes onChange event on checkbox changed', (done) => { + render(<PropertiesForm + types={{ 'myvalue': 'boolean' }} + value={{ 'myvalue': false }} + onChange={value => { + expect(value).to.have.property('myvalue', true); + done(); + }} + />, document.body); + + let input = document.querySelector('input[name=myvalue]'); + input.click(); + }); + }); +}); diff --git a/test/shared/commands/parsers.test.js b/test/shared/commands/parsers.test.js new file mode 100644 index 0000000..0a1960c --- /dev/null +++ b/test/shared/commands/parsers.test.js @@ -0,0 +1,85 @@ +import { expect } from "chai"; +import * as parsers from 'shared/commands/parsers'; + +describe("shared/commands/parsers", () => { + describe("#parsers.parseSetOption", () => { + it('parse set string', () => { + let [key, value] = parsers.parseSetOption('encoding=utf-8', { encoding: 'string' }); + expect(key).to.equal('encoding'); + expect(value).to.equal('utf-8'); + }); + + it('parse set empty string', () => { + let [key, value] = parsers.parseSetOption('encoding=', { encoding: 'string' }); + expect(key).to.equal('encoding'); + expect(value).to.equal(''); + }); + + it('parse set string', () => { + let [key, value] = parsers.parseSetOption('history=50', { history: 'number' }); + expect(key).to.equal('history'); + expect(value).to.equal(50); + }); + + it('parse set boolean', () => { + let [key, value] = parsers.parseSetOption('paste', { paste: 'boolean' }); + expect(key).to.equal('paste'); + expect(value).to.be.true; + + [key, value] = parsers.parseSetOption('nopaste', { paste: 'boolean' }); + expect(key).to.equal('paste'); + expect(value).to.be.false; + }); + + it('throws error on unknown property', () => { + expect(() => parsers.parseSetOption('charset=utf-8', {})).to.throw(Error, 'Unknown'); + expect(() => parsers.parseSetOption('smoothscroll', {})).to.throw(Error, 'Unknown'); + expect(() => parsers.parseSetOption('nosmoothscroll', {})).to.throw(Error, 'Unknown'); + }) + + it('throws error on invalid property', () => { + expect(() => parsers.parseSetOption('charset=utf-8', { charset: 'number' })).to.throw(Error, 'Not number'); + expect(() => parsers.parseSetOption('charset=utf-8', { charset: 'boolean' })).to.throw(Error, 'Invalid'); + expect(() => parsers.parseSetOption('charset=', { charset: 'boolean' })).to.throw(Error, 'Invalid'); + expect(() => parsers.parseSetOption('smoothscroll', { smoothscroll: 'string' })).to.throw(Error, 'Invalid'); + expect(() => parsers.parseSetOption('smoothscroll', { smoothscroll: 'number' })).to.throw(Error, 'Invalid'); + }) + }); + + describe('#normalizeUrl', () => { + const config = { + default: 'google', + engines: { + google: 'https://google.com/search?q={}', + yahoo: 'https://yahoo.com/search?q={}', + } + }; + + it('convertes search url', () => { + expect(parsers.normalizeUrl(['google', 'apple'], config)) + .to.equal('https://google.com/search?q=apple'); + expect(parsers.normalizeUrl(['yahoo', 'apple'], config)) + .to.equal('https://yahoo.com/search?q=apple'); + expect(parsers.normalizeUrl(['google', 'apple', 'banana'], config)) + .to.equal('https://google.com/search?q=apple%20banana'); + expect(parsers.normalizeUrl(['yahoo', 'C++CLI'], config)) + .to.equal('https://yahoo.com/search?q=C%2B%2BCLI'); + }); + + it('user default search engine', () => { + expect(parsers.normalizeUrl(['apple', 'banana'], config)) + .to.equal('https://google.com/search?q=apple%20banana'); + }); + }); + + describe('#parseCommandLine', () => { + it('parse command line as name and args', () => { + expect(parsers.parseCommandLine('open google apple')).to.deep.equal(['open', ['google', 'apple']]); + expect(parsers.parseCommandLine(' open google apple ')).to.deep.equal(['open', ['google', 'apple']]); + expect(parsers.parseCommandLine('')).to.deep.equal(['', []]); + expect(parsers.parseCommandLine(' ')).to.deep.equal(['', []]); + expect(parsers.parseCommandLine('exit')).to.deep.equal(['exit', []]); + expect(parsers.parseCommandLine(' exit ')).to.deep.equal(['exit', []]); + }); + }); +}); diff --git a/test/shared/validators/setting.test.js b/test/shared/settings/validator.test.js index 15d6a10..61d976a 100644 --- a/test/shared/validators/setting.test.js +++ b/test/shared/settings/validator.test.js @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { validate } from 'shared/validators/setting'; +import { validate } from 'shared/settings/validator'; describe("setting validator", () => { describe("unknown top keys", () => { diff --git a/test/shared/settings/values.test.js b/test/shared/settings/values.test.js index 2632cd7..62cfb5f 100644 --- a/test/shared/settings/values.test.js +++ b/test/shared/settings/values.test.js @@ -7,13 +7,21 @@ describe("settings values", () => { let json = `{ "keymaps": { "0": {"type": "scroll.home"}}, "search": { "default": "google", "engines": { "google": "https://google.com/search?q={}" }}, - "blacklist": [ "*.slack.com"] + "blacklist": [ "*.slack.com"], + "properties": { + "mystr": "value", + "mynum": 123, + "mybool": true + } }`; let value = values.valueFromJson(json); expect(value.keymaps).to.deep.equal({ 0: {type: "scroll.home"}}); expect(value.search).to.deep.equal({ default: "google", engines: { google: "https://google.com/search?q={}"} }); expect(value.blacklist).to.deep.equal(["*.slack.com"]); + expect(value.properties).to.have.property('mystr', 'value'); + expect(value.properties).to.have.property('mynum', 123); + expect(value.properties).to.have.property('mybool', true); }); }); @@ -29,6 +37,11 @@ describe("settings values", () => { engines: [['google', 'https://google.com/search?q={}']], }, blacklist: ['*.slack.com'], + "properties": { + "mystr": "value", + "mynum": 123, + "mybool": true, + } }; let value = values.valueFromForm(form); @@ -37,6 +50,9 @@ describe("settings values", () => { expect(JSON.stringify(value.search)).to.deep.equal(JSON.stringify({ default: "google", engines: { google: "https://google.com/search?q={}"} })); expect(value.search).to.deep.equal({ default: "google", engines: { google: "https://google.com/search?q={}"} }); expect(value.blacklist).to.deep.equal(["*.slack.com"]); + expect(value.properties).to.have.property('mystr', 'value'); + expect(value.properties).to.have.property('mynum', 123); + expect(value.properties).to.have.property('mybool', true); }); it('convert from empty form', () => { @@ -45,6 +61,7 @@ describe("settings values", () => { expect(value).to.not.have.key('keymaps'); expect(value).to.not.have.key('search'); expect(value).to.not.have.key('blacklist'); + expect(value).to.not.have.key('properties'); }); it('override keymaps', () => { @@ -96,7 +113,12 @@ describe("settings values", () => { 0: { type: 'scroll.home' }, }, search: { default: 'google', engines: { google: 'https://google.com/search?q={}' }}, - blacklist: [ '*.slack.com'] + blacklist: [ '*.slack.com'], + properties: { + "mystr": "value", + "mynum": 123, + "mybool": true, + } }; let allowed = ['scroll.vertically?{"count":1}', 'scroll.home' ]; let form = values.formFromValue(value, allowed); @@ -109,6 +131,9 @@ describe("settings values", () => { expect(form.search).to.have.deep.property('engines', [['google', 'https://google.com/search?q={}']]); expect(form.blacklist).to.have.lengthOf(1); expect(form.blacklist).to.include('*.slack.com'); + expect(form.properties).to.have.property('mystr', 'value'); + expect(form.properties).to.have.property('mynum', 123); + expect(form.properties).to.have.property('mybool', true); }); }); }); |