Merge pull request #248 from ueokande/gui-settings

GUI Settings
jh-changes
Shin'ya Ueoka 7 years ago committed by GitHub
commit 0b37c2250e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .eslintrc
  2. 2
      karma.conf.js
  3. 98
      package-lock.json
  4. 9
      package.json
  5. 19
      src/settings/actions/setting.js
  6. 52
      src/settings/components/form/blacklist-form.jsx
  7. 9
      src/settings/components/form/blacklist-form.scss
  8. 99
      src/settings/components/form/keymaps-form.jsx
  9. 9
      src/settings/components/form/keymaps-form.scss
  10. 78
      src/settings/components/form/search-form.jsx
  11. 28
      src/settings/components/form/search-form.scss
  12. 151
      src/settings/components/index.jsx
  13. 19
      src/settings/components/site.scss
  14. 12
      src/settings/components/ui/add-button.jsx
  15. 13
      src/settings/components/ui/add-button.scss
  16. 12
      src/settings/components/ui/delete-button.jsx
  17. 13
      src/settings/components/ui/delete-button.scss
  18. 52
      src/settings/components/ui/input.jsx
  19. 29
      src/settings/components/ui/input.scss
  20. 5
      src/settings/index.jsx
  21. 2
      src/settings/reducers/setting.js
  22. 67
      src/shared/settings/default.js
  23. 100
      src/shared/settings/values.js
  24. 13
      src/shared/store/provider.jsx
  25. 82
      test/settings/components/form/blacklist-form.test.jsx
  26. 53
      test/settings/components/form/keymaps-form.test.jsx
  27. 104
      test/settings/components/form/search-engine-form.test.jsx
  28. 83
      test/settings/components/ui/input.test.jsx
  29. 112
      test/shared/settings/values.test.js
  30. 2
      webpack.config.js

@ -36,6 +36,7 @@
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "off",
"no-bitwise": "off",
"no-console": ["error", { "allow": ["warn", "error"] }],
"no-empty-function": "off",
@ -44,6 +45,8 @@
"no-plusplus": "off",
"no-ternary": "off",
"no-undefined": "off",
"no-undef-init": "off",
"no-unused-vars": ["error", { "varsIgnorePattern": "h" }],
"no-use-before-define": "off",
"no-warning-comments": "off",
"object-curly-newline": ["error", { "consistent": true }],
@ -65,5 +68,6 @@
"react/jsx-indent": ["error", 2],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off"
}
}

@ -7,11 +7,13 @@ module.exports = function (config) {
frameworks: ['mocha'],
files: [
'test/**/*.test.js',
'test/**/*.test.jsx',
'test/**/*.html'
],
preprocessors: {
'test/**/*.test.js': [ 'webpack' ],
'test/**/*.test.jsx': [ 'webpack' ],
'test/**/*.html': ['html2js']
},

98
package-lock.json generated

@ -735,12 +735,6 @@
"babel-helper-is-void-0": "0.2.0"
}
},
"babel-plugin-syntax-flow": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz",
"integrity": "sha1-TDqyCiryaqIM0lmVw5jE63AxDI0=",
"dev": true
},
"babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
@ -981,16 +975,6 @@
"regexpu-core": "2.0.0"
}
},
"babel-plugin-transform-flow-strip-types": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz",
"integrity": "sha1-hMtnKTXUNxT9wyvOhFaNh0Qc988=",
"dev": true,
"requires": {
"babel-plugin-syntax-flow": "6.18.0",
"babel-runtime": "6.25.0"
}
},
"babel-plugin-transform-inline-consecutive-adds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.2.0.tgz",
@ -1024,15 +1008,6 @@
"esutils": "2.0.2"
}
},
"babel-plugin-transform-react-display-name": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz",
"integrity": "sha1-Z+K/Hx6ck6sI25Z5LgU5K/LMKNE=",
"dev": true,
"requires": {
"babel-runtime": "6.25.0"
}
},
"babel-plugin-transform-react-jsx": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz",
@ -1044,26 +1019,6 @@
"babel-runtime": "6.25.0"
}
},
"babel-plugin-transform-react-jsx-self": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz",
"integrity": "sha1-322AqdomEqEh5t3XVYvL7PBuY24=",
"dev": true,
"requires": {
"babel-plugin-syntax-jsx": "6.18.0",
"babel-runtime": "6.25.0"
}
},
"babel-plugin-transform-react-jsx-source": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz",
"integrity": "sha1-ZqwSFT9c0tF7PBkmj0vwGX9E7NY=",
"dev": true,
"requires": {
"babel-plugin-syntax-jsx": "6.18.0",
"babel-runtime": "6.25.0"
}
},
"babel-plugin-transform-regenerator": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.24.1.tgz",
@ -1165,15 +1120,6 @@
"babel-plugin-transform-regenerator": "6.24.1"
}
},
"babel-preset-flow": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz",
"integrity": "sha1-5xIYiHCFrpoktb5Baa/7WZgWxJ0=",
"dev": true,
"requires": {
"babel-plugin-transform-flow-strip-types": "6.22.0"
}
},
"babel-preset-minify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/babel-preset-minify/-/babel-preset-minify-0.2.0.tgz",
@ -1205,18 +1151,14 @@
"lodash.isplainobject": "4.0.6"
}
},
"babel-preset-react": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.24.1.tgz",
"integrity": "sha1-umnfrqRfw+xjm2pOzqbhdwLJE4A=",
"babel-preset-preact": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/babel-preset-preact/-/babel-preset-preact-1.1.0.tgz",
"integrity": "sha1-NaxlWnOkm4Q4FjzgU4Fld+GYCGE=",
"dev": true,
"requires": {
"babel-plugin-syntax-jsx": "6.18.0",
"babel-plugin-transform-react-display-name": "6.25.0",
"babel-plugin-transform-react-jsx": "6.24.1",
"babel-plugin-transform-react-jsx-self": "6.22.0",
"babel-plugin-transform-react-jsx-source": "6.22.0",
"babel-preset-flow": "6.23.0"
"babel-plugin-transform-react-jsx": "6.24.1"
}
},
"babel-register": {
@ -6273,6 +6215,12 @@
"uniqs": "2.0.0"
}
},
"preact": {
"version": "8.2.6",
"resolved": "https://registry.npmjs.org/preact/-/preact-8.2.6.tgz",
"integrity": "sha1-ACi0Ju+Y/Mp0Gjxhf/W4E7mpR8c=",
"dev": true
},
"prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@ -6491,30 +6439,6 @@
}
}
},
"react": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.0.0.tgz",
"integrity": "sha1-zn348ZQbA28Cssyp29DLHw6FXi0=",
"dev": true,
"requires": {
"fbjs": "0.8.16",
"loose-envify": "1.3.1",
"object-assign": "4.1.1",
"prop-types": "15.6.0"
}
},
"react-dom": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.0.0.tgz",
"integrity": "sha1-nMMHnD3NcNTG4BuEqrKn40wwP1g=",
"dev": true,
"requires": {
"fbjs": "0.8.16",
"loose-envify": "1.3.1",
"object-assign": "4.1.1",
"prop-types": "15.6.0"
}
},
"read-pkg": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",

@ -3,10 +3,9 @@
"description": "Vim vixen",
"scripts": {
"start": "webpack -w --debug --devtool inline-source-map",
"lint": "eslint --ext .jsx,.js src",
"build": "NODE_ENV=production webpack --progress --display-error-details",
"package": "npm run build && ./package.sh",
"lint": "eslint src",
"lint": "eslint --ext .jsx,.js src",
"test": "karma start"
},
"repository": {
@ -24,9 +23,8 @@
"babel-eslint": "^7.2.3",
"babel-loader": "^7.1.1",
"babel-minify-webpack-plugin": "^0.2.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-preact": "^1.1.0",
"chai": "^4.1.1",
"css-loader": "^0.28.4",
"eslint": "^4.7.0",
@ -41,8 +39,7 @@
"karma-webpack": "^2.0.4",
"mocha": "^3.5.0",
"node-sass": "^4.5.3",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"preact": "^8.2.6",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"webpack": "^3.5.3"

@ -1,13 +1,14 @@
import actions from 'settings/actions';
import messages from 'shared/messages';
import DefaultSettings from 'shared/default-settings';
import DefaultSettings from 'shared/settings/default';
import * as settingsValues from 'shared/settings/values';
const load = () => {
return browser.storage.local.get('settings').then(({ settings }) => {
if (settings) {
return set(settings);
}
if (!settings) {
return set(DefaultSettings);
}
return set(Object.assign({}, DefaultSettings, settings));
}, console.error);
};
@ -24,11 +25,19 @@ const save = (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,
};
};

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

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

@ -0,0 +1,99 @@
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 leftmost'],
['scroll.end', 'Scroll last'],
['scroll.pages?{"count":-0.5}', 'Scroll up by half of screen'],
['scroll.pages?{"count":0.5}', 'Scroll up by half of screen'],
['scroll.pages?{"count":-1}', 'Scroll up by a screen'],
['scroll.pages?{"count":1}', 'Scroll up 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":true}', 'Reload current tab'],
['tabs.pin.toggle', 'Toggle pinned state'],
['tabs.duplicate', 'Dupplicate 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'],
], [
['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'],
['zoom.in', 'Zoom-in'],
['zoom.out', 'Zoom-out'],
['zoom.neutral', 'Reset zoom level'],
]
];
class KeymapsForm extends Component {
render() {
let values = this.props.value;
if (!values) {
values = {};
}
return <div className='keymap-fields'>
{
KeyMapFields.map((group, index) => {
return <div key={index} className='form-keymaps-form'>
{
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);
}
}
export default KeymapsForm;

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

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

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

@ -1,16 +1,27 @@
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 * as settingActions from 'settings/actions/setting';
import * as validator from 'shared/validators/setting';
import * as settingsValues from 'shared/settings/values';
class SettingsComponent extends React.Component {
const DO_YOU_WANT_TO_CONTINUE =
'Some settings in JSON can be lose on 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 +37,140 @@ 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>
</div>;
}
renderJsonFields() {
return <div>
<Input
type='textarea'
name='json'
label='Plane JSON'
spellCheck='false'
error={this.state.errors.json}
onChange={this.bindValue.bind(this)}
value={this.state.settings.json}
/>
</div>;
}
render() {
let fields = null;
if (this.state.settings.source === 'form') {
fields = this.renderFormFields();
} else if (this.state.settings.source === 'json') {
fields = this.renderJsonFields();
}
return (
<div>
<h1>Configure Vim-Vixen</h1>
<form className='vimvixen-settings-form'>
<Input
type='radio'
id='setting-source-form'
name='source'
label='Use form'
checked={this.state.settings.source === 'form'}
value='form'
onChange={this.bindSource.bind(this)} />
<p>Load settings from:</p>
<input type='radio' id='setting-source-json'
<Input
type='radio'
name='source'
label='Use plain JSON'
checked={this.state.settings.source === 'json'}
value='json'
onChange={this.bindAndSave.bind(this)}
checked={this.state.settings.source === 'json'} />
<label htmlFor='settings-source-json'>JSON</label>
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} />
{ 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;
this.setState({ settings: nextSettings });
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));
}
bindAndSave(e) {
this.bindValue(e);
bindValue(e) {
let next = Object.assign({}, this.state);
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;
}
next.settings[e.target.name] = e.target.value;
this.setState(next);
this.context.store.dispatch(settingActions.save(next.settings));
}
bindSource(e) {
let from = this.state.settings.source;
let to = e.target.value;
let next = Object.assign({}, this.state);
if (from === 'form' && to === 'json') {
next.settings.json =
settingsValues.jsonFromForm(this.state.settings.form);
} else if (from === 'json' && to === 'form') {
let b = window.confirm(DO_YOU_WANT_TO_CONTINUE);
if (!b) {
this.setState(this.state);
return;
}
next.settings.form =
settingsValues.formFromJson(this.state.settings.json);
}
next.settings.source = to;
SettingsComponent.contextTypes = {
store: PropTypes.any,
};
this.setState(next);
this.context.store.dispatch(settingActions.save(next.settings));
}
}
export default SettingsComponent;

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

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

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

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

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

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

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

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

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

@ -62,5 +62,70 @@ export default {
"wikipedia": "https://en.wikipedia.org/w/index.php?search={}"
}
}
}`
}`,
'form': {
'keymaps': {
'scroll.vertically?{"count":1}': 'j',
'scroll.vertically?{"count":-1}': 'k',
'scroll.horizonally?{"count":-1}': 'h',
'scroll.horizonally?{"count":1}': 'l',
'scroll.home': '0',
'scroll.end': '$',
'scroll.pages?{"count":-0.5}': '<C-U>',
'scroll.pages?{"count":0.5}': '<C-D>',
'scroll.pages?{"count":-1}': '<C-B>',
'scroll.pages?{"count":1}': '<C-F>',
'tabs.close': 'd',
'tabs.reopen': 'u',
'tabs.next?{"count":1}': 'J',
'tabs.prev?{"count":1}': 'K',
'tabs.first': 'g0',
'tabs.last': 'g$',
'tabs.reload?{"cache":true}': 'r',
'tabs.pin.toggle': 'zp',
'tabs.duplicate': 'zd',
'follow.start?{"newTab":false}': 'f',
'follow.start?{"newTab":true}': 'F',
'navigate.history.prev': 'H',
'navigate.history.next': 'L',
'navigate.link.next': ']]',
'navigate.link.prev': '[[',
'navigate.parent': 'gu',
'navigate.root': 'gU',
'find.start': '/',
'find.next': 'n',
'find.prev': 'N',
'command.show': ':',
'command.show.open?{"alter":false}': 'o',
'command.show.open?{"alter":true}': 'O',
'command.show.tabopen?{"alter":false}': 't',
'command.show.tabopen?{"alter":true}': 'T',
'command.show.winopen?{"alter":false}': 'w',
'command.show.winopen?{"alter":true}': 'W',
'command.show.buffer': 'b',
'addon.toggle.enabled': '<S-Esc>',
'urls.yank': 'y',
'zoom.in': 'zi',
'zoom.out': 'zo',
'zoom.neutral': 'zz',
},
'search': {
'default': 'google',
'engines': [
['google', 'https,//google.com/search?q={}'],
['yahoo', 'https,//search.yahoo.com/search?p={}'],
['bing', 'https,//www.bing.com/search?q={}'],
['duckduckgo', 'https,//duckduckgo.com/?q={}'],
['twitter', 'https,//twitter.com/search?q={}'],
['wikipedia', 'https,//en.wikipedia.org/w/index.php?search={}'],
]
},
'blacklist': [],
}
};

@ -0,0 +1,100 @@
import DefaultSettings from './default';
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;
}
}
}
let blacklist = form.blacklist;
return { keymaps, search, blacklist };
};
const jsonFromValue = (value) => {
return JSON.stringify(value, undefined, 2);
};
const formFromValue = (value) => {
let keymaps = undefined;
if (value.keymaps) {
let allowedOps = new Set(Object.keys(DefaultSettings.form.keymaps));
keymaps = {};
for (let keys of Object.keys(value.keymaps)) {
let op = operationToFormName(value.keymaps[keys]);
if (allowedOps.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 blacklist = value.blacklist;
return { keymaps, search, blacklist };
};
const jsonFromForm = (form) => {
return jsonFromValue(valueFromForm(form));
};
const formFromJson = (json) => {
let value = valueFromJson(json);
return formFromValue(value);
};
export {
valueFromJson, valueFromForm, jsonFromValue, formFromValue,
jsonFromForm, formFromJson
};

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

@ -0,0 +1,82 @@
import { expect } from 'chai';
import { h, render } from 'preact';
import BlacklistForm from 'settings/components/form/blacklist-form'
describe("settings/form/BlacklistForm", () => {
beforeEach(() => {
document.body.innerHTML = '';
});
describe('render', () => {
it('renders BlacklistForm', () => {
render(<BlacklistForm value={['*.slack.com', 'www.google.com/maps']} />, document.body);
let inputs = document.querySelectorAll('input[type=text]');
expect(inputs).to.have.lengthOf(2);
expect(inputs[0].value).to.equal('*.slack.com');
expect(inputs[1].value).to.equal('www.google.com/maps');
});
it('renders blank value', () => {
render(<BlacklistForm />, document.body);
let inputs = document.querySelectorAll('input[type=text]');
expect(inputs).to.be.empty;
});
it('renders blank value', () => {
render(<BlacklistForm />, document.body);
let inputs = document.querySelectorAll('input[type=text]');
expect(inputs).to.be.empty;
});
});
describe('onChange', () => {
it('invokes onChange event on edit', (done) => {
render(<BlacklistForm
value={['*.slack.com', 'www.google.com/maps*']}
onChange={value => {
expect(value).to.have.lengthOf(2)
.and.have.members(['gitter.im', 'www.google.com/maps*']);
done();
}}
/>, document.body);
let input = document.querySelectorAll('input[type=text]')[0];
input.value = 'gitter.im';
input.dispatchEvent(new Event('change'))
});
it('invokes onChange event on delete', (done) => {
render(<BlacklistForm
value={['*.slack.com', 'www.google.com/maps*']}
onChange={value => {
expect(value).to.have.lengthOf(1)
.and.have.members(['www.google.com/maps*']);
done();
}}
/>, document.body);
let button = document.querySelectorAll('input[type=button]')[0];
button.click();
});
it('invokes onChange event on add', (done) => {
render(<BlacklistForm
value={['*.slack.com']}
onChange={value => {
expect(value).to.have.lengthOf(2)
.and.have.members(['*.slack.com', '']);
done();
}}
/>, document.body);
let button = document.querySelector('input[type=button].ui-add-button');
button.click();
});
});
});

@ -0,0 +1,53 @@
import { expect } from 'chai';
import { h, render } from 'preact';
import KeymapsForm from 'settings/components/form/keymaps-form'
describe("settings/form/KeymapsForm", () => {
beforeEach(() => {
document.body.innerHTML = '';
});
describe('render', () => {
it('renders KeymapsForm', () => {
render(<KeymapsForm value={{
'scroll.vertically?{"count":1}': 'j',
'scroll.vertically?{"count":-1}': 'k',
}} />, document.body);
let inputj = document.getElementById('scroll.vertically?{"count":1}');
let inputk = document.getElementById('scroll.vertically?{"count":-1}');
expect(inputj.value).to.equal('j');
expect(inputk.value).to.equal('k');
});
it('renders blank value', () => {
render(<KeymapsForm />, document.body);
let inputj = document.getElementById('scroll.vertically?{"count":1}');
let inputk = document.getElementById('scroll.vertically?{"count":-1}');
expect(inputj.value).to.be.empty;
expect(inputk.value).to.be.empty;
});
});
describe('onChange event', () => {
it('invokes onChange event on edit', (done) => {
render(<KeymapsForm
value={{
'scroll.vertically?{"count":1}': 'j',
'scroll.vertically?{"count":-1}': 'k',
}}
onChange={value => {
expect(value['scroll.vertically?{"count":1}']).to.equal('jjj');
done();
}} />, document.body);
let input = document.getElementById('scroll.vertically?{"count":1}');
input.value = 'jjj';
input.dispatchEvent(new Event('change'))
});
});
});

@ -0,0 +1,104 @@
import { expect } from 'chai';
import { h, render } from 'preact';
import SearchForm from 'settings/components/form/search-form'
describe("settings/form/SearchForm", () => {
beforeEach(() => {
document.body.innerHTML = '';
});
describe('render', () => {
it('renders SearchForm', () => {
render(<SearchForm value={{
default: 'google',
engines: [['google', 'google.com'], ['yahoo', 'yahoo.com']],
}} />, document.body);
let names = document.querySelectorAll('input[name=name]');
expect(names).to.have.lengthOf(2);
expect(names[0].value).to.equal('google');
expect(names[1].value).to.equal('yahoo');
let urls = document.querySelectorAll('input[name=url]');
expect(urls).to.have.lengthOf(2);
expect(urls[0].value).to.equal('google.com');
expect(urls[1].value).to.equal('yahoo.com');
});
it('renders blank value', () => {
render(<SearchForm />, document.body);
let names = document.querySelectorAll('input[name=name]');
let urls = document.querySelectorAll('input[name=url]');
expect(names).to.have.lengthOf(0);
expect(urls).to.have.lengthOf(0);
});
it('renders blank engines', () => {
render(<SearchForm value={{ default: 'google' }} />, document.body);
let names = document.querySelectorAll('input[name=name]');
let urls = document.querySelectorAll('input[name=url]');
expect(names).to.have.lengthOf(0);
expect(urls).to.have.lengthOf(0);
});
});
describe('onChange event', () => {
it('invokes onChange event on edit', (done) => {
render(<SearchForm
value={{
default: 'google',
engines: [['google', 'google.com'], ['yahoo', 'yahoo.com']]
}}
onChange={value => {
expect(value.default).to.equal('louvre');
expect(value.engines).to.have.lengthOf(2)
.and.have.deep.members([['louvre', 'google.com'], ['yahoo', 'yahoo.com']])
done();
}} />, document.body);
let radio = document.querySelectorAll('input[type=radio]');
radio.checked = true;
let name = document.querySelector('input[name=name]');
name.value = 'louvre';
name.dispatchEvent(new Event('change'))
});
it('invokes onChange event on delete', (done) => {
render(<SearchForm value={{
default: 'yahoo',
engines: [['louvre', 'google.com'], ['yahoo', 'yahoo.com']]
}}
onChange={value => {
expect(value.default).to.equal('yahoo');
expect(value.engines).to.have.lengthOf(1)
.and.have.deep.members([['yahoo', 'yahoo.com']])
done();
}} />, document.body);
let button = document.querySelector('input[type=button]');
button.click();
});
it('invokes onChange event on add', (done) => {
render(<SearchForm value={{
default: 'yahoo',
engines: [['google', 'google.com']]
}}
onChange={value => {
expect(value.default).to.equal('yahoo');
expect(value.engines).to.have.lengthOf(2)
.and.have.deep.members([['google', 'google.com'], ['', '']])
done();
}} />, document.body);
let button = document.querySelector('input[type=button].ui-add-button');
button.click();
});
});
});

@ -0,0 +1,83 @@
import { expect } from 'chai';
import { h, render } from 'preact';
import Input from 'settings/components/ui/input'
describe("settings/ui/Input", () => {
beforeEach(() => {
document.body.innerHTML = '';
});
context("type=text", () => {
it('renders text input', () => {
render(<Input type='text' name='myname' label='myfield' value='myvalue'/>, document.body)
let label = document.querySelector('label');
let input = document.querySelector('input');
expect(label.textContent).to.contain('myfield');
expect(input.type).to.contain('text');
expect(input.name).to.contain('myname');
expect(input.value).to.contain('myvalue');
});
it('invoke onChange', (done) => {
render(<Input type='text' name='myname' label='myfield' value='myvalue' onChange={(e) => {
expect(e.target.value).to.equal('newvalue');
done();
}}/>, document.body);
let input = document.querySelector('input');
input.value = 'newvalue';
input.dispatchEvent(new Event('change'))
});
});
context("type=radio", () => {
it('renders radio button', () => {
render(<Input type='radio' name='myname' label='myfield' value='myvalue'/>, document.body)
let label = document.querySelector('label');
let input = document.querySelector('input');
expect(label.textContent).to.contain('myfield');
expect(input.type).to.contain('radio');
expect(input.name).to.contain('myname');
expect(input.value).to.contain('myvalue');
});
it('invoke onChange', (done) => {
render(<Input type='text' name='radio' label='myfield' value='myvalue' onChange={(e) => {
expect(e.target.checked).to.be.true;
done();
}}/>, document.body);
let input = document.querySelector('input');
input.checked = true;
input.dispatchEvent(new Event('change'))
});
});
context("type=textarea", () => {
it('renders textarea button', () => {
render(<Input type='textarea' name='myname' label='myfield' value='myvalue' error='myerror' />, document.body)
let label = document.querySelector('label');
let textarea = document.querySelector('textarea');
let error = document.querySelector('.settings-ui-input-error');
expect(label.textContent).to.contain('myfield');
expect(textarea.nodeName).to.contain('TEXTAREA');
expect(textarea.name).to.contain('myname');
expect(textarea.value).to.contain('myvalue');
expect(error.textContent).to.contain('myerror');
});
it('invoke onChange', (done) => {
render(<Input type='textarea' name='myname' label='myfield' value='myvalue' onChange={(e) => {
expect(e.target.value).to.equal('newvalue');
done();
}}/>, document.body);
let input = document.querySelector('textarea');
input.value = 'newvalue'
input.dispatchEvent(new Event('change'))
});
});
});

@ -0,0 +1,112 @@
import { expect } from 'chai';
import * as values from 'shared/settings/values';
describe("settings values", () => {
describe('valueFromJson', () => {
it('return object from json string', () => {
let json = `{
"keymaps": { "0": {"type": "scroll.home"}},
"search": { "default": "google", "engines": { "google": "https://google.com/search?q={}" }},
"blacklist": [ "*.slack.com"]
}`;
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"]);
});
});
describe('valueFromForm', () => {
it('returns value from form', () => {
let form = {
keymaps: {
'scroll.vertically?{"count":1}': 'j',
'scroll.home': '0',
},
search: {
default: 'google',
engines: [['google', 'https://google.com/search?q={}']],
},
blacklist: ['*.slack.com'],
};
let value = values.valueFromForm(form);
expect(value.keymaps).to.have.deep.property('j', { type: "scroll.vertically", count: 1 });
expect(value.keymaps).to.have.deep.property('0', { type: "scroll.home" });
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"]);
});
it('convert from empty form', () => {
let form = {};
let value = values.valueFromForm(form);
expect(value).to.not.have.key('keymaps');
expect(value).to.not.have.key('search');
expect(value).to.not.have.key('blacklist');
});
it('override keymaps', () => {
let form = {
keymaps: {
'scroll.vertically?{"count":1}': 'j',
'scroll.vertically?{"count":-1}': 'j',
}
};
let value = values.valueFromForm(form);
expect(value.keymaps).to.have.key('j');
});
it('override search engine', () => {
let form = {
search: {
default: 'google',
engines: [
['google', 'https://google.com/search?q={}'],
['google', 'https://google.co.jp/search?q={}'],
]
}
};
let value = values.valueFromForm(form);
expect(value.search.engines).to.have.property('google', 'https://google.co.jp/search?q={}');
});
});
describe('jsonFromValue', () => {
});
describe('formFromValue', () => {
it('convert empty value to form', () => {
let value = {};
let form = values.formFromValue(value);
expect(value).to.not.have.key('keymaps');
expect(value).to.not.have.key('search');
expect(value).to.not.have.key('blacklist');
});
it('convert value to form', () => {
let value = {
keymaps: {
j: { type: 'scroll.vertically', count: 1 },
JJ: { type: 'scroll.vertically', count: 100 },
0: { type: 'scroll.home' },
},
search: { default: 'google', engines: { google: 'https://google.com/search?q={}' }},
blacklist: [ '*.slack.com']
};
let form = values.formFromValue(value);
expect(form.keymaps).to.have.property('scroll.vertically?{"count":1}', 'j');
expect(form.keymaps).to.have.property('scroll.home', '0');
expect(Object.keys(form.keymaps)).to.have.lengthOf(2);
expect(form.search).to.have.property('default', 'google');
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');
});
});
});

@ -25,7 +25,7 @@ config = {
exclude: /node_modules/,
loader: 'babel-loader',
query: {
presets: ['es2015', 'react']
presets: ['es2015', 'preact']
}
},
{