diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0e9e5938..1f2a3c1b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1051,6 +1051,15 @@ }, "description": "Error displayed when the value of @var color is invalid" }, + "styleMetaErrorRangeOrNumber": { + "message": "Invalid @var $type$: value must be an array containing at least one number at index zero", + "description": "Error displayed when the value of @var number or @var range is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, "styleMetaErrorPreprocessor": { "message": "Unsupported @preprocessor: $preprocessor$", "placeholders": { diff --git a/js/usercss.js b/js/usercss.js index 8f5e6eda..220acef7 100644 --- a/js/usercss.js +++ b/js/usercss.js @@ -29,7 +29,7 @@ var usercss = (() => { ['version', 0], ]); const MANDATORY_META = ['name', 'namespace', 'version']; - const META_VARS = ['text', 'color', 'checkbox', 'select', 'dropdown', 'image']; + const META_VARS = ['text', 'color', 'checkbox', 'select', 'dropdown', 'image', 'number', 'range']; const META_URLS = [...KNOWN_META.keys()].filter(k => k.endsWith('URL')); const BUILDER = { @@ -194,12 +194,40 @@ var usercss = (() => { state.errorPrefix = 'Invalid JSON: '; parseJSONValue(state); state.errorPrefix = ''; + const extractDefaultOption = (key, value) => { + if (key.endsWith('*')) { + const option = createOption(key.slice(0, -1), value); + result.default = option.name; + return option; + } + return createOption(key, value); + }; if (Array.isArray(state.value)) { - result.options = state.value.map(text => createOption(text)); + result.options = state.value.map(k => extractDefaultOption(k)); } else { - result.options = Object.keys(state.value).map(k => createOption(k, state.value[k])); + result.options = Object.keys(state.value).map(k => extractDefaultOption(k, state.value[k])); + } + if (result.default === null) { + result.default = (result.options[0] || {}).name || ''; + } + break; + } + + case 'number': + case 'range': { + state.errorPrefix = 'Invalid JSON: '; + parseJSONValue(state); + state.errorPrefix = ''; + // [default, start, end, step, units] (start, end, step & units are optional) + if (Array.isArray(state.value) && state.value.length) { + // label may be placed anywhere + result.units = (state.value.find(i => typeof i === 'string') || '').replace(/[\d.+-]/g, ''); + const range = state.value.filter(i => typeof i === 'number' || i === null); + result.default = range[0]; + result.min = range[1]; + result.max = range[2]; + result.step = range[3] === 0 ? 1 : range[3]; } - result.default = (result.options[0] || {}).name || ''; break; } @@ -541,6 +569,9 @@ var usercss = (() => { // TODO: handle customized image return va.options.find(o => o.name === va[prop]).value; } + if ((va.type === 'number' || va.type === 'range') && va.units) { + return va[prop] + va.units; + } return va[prop]; } @@ -578,6 +609,8 @@ var usercss = (() => { throw new Error(chrome.i18n.getMessage('styleMetaErrorCheckbox')); } else if (va.type === 'color') { va[value] = colorConverter.format(colorConverter.parse(va[value]), 'rgb'); + } else if ((va.type === 'number' || va.type === 'range') && typeof va[value] !== 'number') { + throw new Error(chrome.i18n.getMessage('styleMetaErrorRangeOrNumber', va.type)); } } diff --git a/manage/config-dialog.css b/manage/config-dialog.css index 9a895641..abdcd89d 100644 --- a/manage/config-dialog.css +++ b/manage/config-dialog.css @@ -98,6 +98,15 @@ margin-right: 4px; } +.current-value { + padding: 2px 4px; + margin-right: 4px; + } + +.config-number span, .config-range span { + line-height: 22px; +} + .config-body label:not(.nondefault) .config-reset-icon { visibility: hidden; } diff --git a/manage/config-dialog.js b/manage/config-dialog.js index cd056ebd..4e0cbab0 100644 --- a/manage/config-dialog.js +++ b/manage/config-dialog.js @@ -258,16 +258,44 @@ function configDialog(style) { ]; break; + case 'range': + case 'number': { + const options = { + va, + type: va.type, + onfocus: va.type === 'number' ? selectAllOnFocus : null, + onblur: va.type === 'number' ? updateVarOnBlur : null, + onchange: updateVarOnChange, + oninput: updateVarOnInput, + required: true + }; + if (typeof va.min === 'number') { + options.min = va.min; + } + if (typeof va.max === 'number') { + options.max = va.max; + } + if (typeof va.step === 'number' && isFinite(va.step)) { + options.step = va.step; + } + children = [ + va.type === 'range' && $create('span.current-value'), + va.input = $create('input.config-value', options) + ]; + break; + } + default: children = [ va.input = $create('input.config-value', { va, - type: 'text', + type: va.type, onchange: updateVarOnChange, oninput: updateVarOnInput, + onfocus: selectAllOnFocus, }), ]; - break; + } resetter = resetter.cloneNode(true); @@ -285,8 +313,28 @@ function configDialog(style) { } } + function updateVarOnBlur() { + this.value = isDefault(this.va) ? this.va.default : this.va.value; + } + function updateVarOnChange() { - this.va.value = this.type !== 'checkbox' ? this.value : this.checked ? '1' : '0'; + if (this.type === 'range') { + this.va.value = Number(this.value); + updateRangeCurrentValue(this.va, this.va.value); + } else if (this.type === 'number') { + if (this.reportValidity()) { + this.va.value = Number(this.value); + } + } else { + this.va.value = this.type !== 'checkbox' ? this.value : this.checked ? '1' : '0'; + } + } + + function updateRangeCurrentValue(va, value) { + const span = $('.current-value', va.input.closest('.config-range')); + if (span) { + span.textContent = value + (va.units || ''); + } } function updateVarOnInput(event, debounced = false) { @@ -297,8 +345,15 @@ function configDialog(style) { } } + function selectAllOnFocus(event) { + event.target.select(); + } + function renderValues(varsToRender = vars) { for (const va of varsToRender) { + if (va.input === document.activeElement) { + continue; + } const value = isDefault(va) ? va.default : va.value; if (va.type === 'color') { va.input.style.backgroundColor = value; @@ -307,6 +362,9 @@ function configDialog(style) { } } else if (va.type === 'checkbox') { va.input.checked = Number(value); + } else if (va.type === 'range') { + va.input.value = value; + updateRangeCurrentValue(va, va.input.value); } else { va.input.value = value; }