/* global $ $create $createLink $remove messageBoxProxy setupLivePrefs */// dom.js /* global API */// msg.js /* global debounce deepCopy */// toolbox.js /* global messageBox */ /* global prefs */ /* global t */// localization.js 'use strict'; /* exported configDialog */ async function configDialog(style) { await require([ '/js/color/color-converter', '/js/color/color-mimicry', '/js/color/color-picker', '/js/color/color-picker.css', '/js/dlg/config-dialog.css', '/options/onoffswitch.css', ]); const AUTOSAVE_DELAY = 500; let saving = false; const data = style.usercssData; const varsHash = deepCopy(data.vars) || {}; const varNames = Object.keys(varsHash); const vars = varNames.map(name => varsHash[name]); let varsInitial = getInitialValues(varsHash); const elements = []; const isInstaller = location.pathname.startsWith('/install-usercss.html'); const isPopup = location.pathname.startsWith('/popup.html'); const colorpicker = ((window.CodeMirror || {}).prototype || window).colorpicker(); const buttons = {}; buildConfigForm(); renderValues(); vars.forEach(renderValueState); return messageBoxProxy.show({ title: `${style.customName || style.name} v${data.version}`, className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''), contents: [ $create('.config-heading', data.supportURL && $createLink({className: '.external-support', href: data.supportURL}, t('externalFeedback'))), $create('.config-body', elements), ], buttons: [{ textContent: t('confirmSave'), dataset: {cmd: 'save'}, disabled: true, onclick: save, }, { textContent: t('genericResetLabel'), title: t('optionsReset'), dataset: {cmd: 'default'}, onclick: useDefault, }, { textContent: t('confirmClose'), dataset: {cmd: 'close'}, }], onshow, }).then(onhide); function getInitialValues(source) { const data = {}; for (const name of varNames) { const va = source[name]; data[name] = isDefault(va) ? va.default : va.value; } return data; } function onshow(box) { $('#message-box-buttons button').after( $create('label#config-autosave-wrapper', { title: t('configOnChangeTooltip'), }, [ $create('input', {id: 'config.autosave', type: 'checkbox'}), $create('SVG:svg.svg-icon.checked', $create('SVG:use', {'xlink:href': '#svg-icon-checked'})), t('configOnChange'), ])); setupLivePrefs(['config.autosave']); if (isPopup) { adjustSizeForPopup(box); } box.on('change', onchange); buttons.save = $('[data-cmd="save"]', box); buttons.default = $('[data-cmd="default"]', box); buttons.close = $('[data-cmd="close"]', box); updateButtons(); } function onhide() { document.body.style.minWidth = ''; document.body.style.minHeight = ''; colorpicker.hide(); } function onchange({target, justSaved = false}) { // invoked after element's own onchange so 'va' contains the updated value const va = target.va; if (va) { va.dirty = varsInitial[va.name] !== (isDefault(va) ? va.default : va.value); if (prefs.get('config.autosave') && !justSaved) { debounce(save, 100, {anyChangeIsDirty: true}); return; } renderValueState(va); if (!justSaved) { updateButtons(); } } } function updateButtons() { const someDirty = vars.some(va => va.dirty); buttons.save.disabled = !someDirty; buttons.default.disabled = vars.every(isDefault); buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose'); } async function save({anyChangeIsDirty = false} = {}) { for (let delay = 1; saving && delay < 1000; delay *= 2) { await new Promise(resolve => setTimeout(resolve, delay)); } if (saving) { throw 'Could not save: still saving previous results...'; } if (!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) { return; } const bgStyle = !isInstaller && await API.styles.get(style.id).catch(() => ({})); style = style.sections ? Object.assign({}, style) : style; style.enabled = true; style.sourceCode = null; style.sections = null; const styleVars = style.usercssData.vars; const bgVars = isInstaller ? styleVars : (bgStyle.usercssData || {}).vars || {}; const invalid = []; let numValid = 0; for (const va of vars) { const bgva = bgVars[va.name]; let error; if (!bgva) { error = 'deleted'; delete styleVars[va.name]; } else if (bgva.type !== va.type) { error = ['type ', '*' + va.type, ' != ', '*' + bgva.type]; } else if ( (va.type === 'select' || va.type === 'dropdown') && !isDefault(va) && bgva.options.every(o => o.name !== va.value) ) { error = `'${va.value}' not in the updated '${va.type}' list`; } else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) { continue; } else { styleVars[va.name].value = va.value; va.savedValue = va.value; numValid++; continue; } invalid.push(['*' + va.name, ': ', ...error].map(e => e[0] === '*' && $create('b', e.slice(1)) || e)); if (bgva) { styleVars[va.name].value = deepCopy(bgva); } } if (invalid.length) { onhide(); messageBox.alert([ $create('div', {style: 'max-width: 34em'}, t('usercssConfigIncomplete')), $create('ol', {style: 'text-align: left'}, invalid.map(msg => $create({tag: 'li', appendChild: msg}))), ], 'pre'); } if (!numValid) { return; } saving = true; try { const newVars = isInstaller ? styleVars : await API.usercss.configVars(style.id, styleVars); varsInitial = getInitialValues(newVars); vars.forEach(va => onchange({target: va.input, justSaved: true})); renderValues(); updateButtons(); $remove('.config-error'); } catch (errors) { const el = $('.config-error', messageBox.element) || $('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error')); el.textContent = el.title = (Array.isArray(errors) ? errors : [errors]) .map(e => e.message || `${e}`) .join('\n'); } saving = false; } function useDefault() { for (const va of vars) { va.value = null; onchange({target: va.input}); } renderValues(); } function isDefault(va) { return va.value === null || va.value === undefined || va.value === va.default; } function buildConfigForm() { let resetter = $create('a.config-reset-icon', {tabIndex: 0}, [ $create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'}, [ $create('SVG:title', t('genericResetLabel')), $create('SVG:polygon', { points: '16.2,5.5 14.5,3.8 10,8.3 5.5,3.8 3.8,5.5 8.3,10 3.8,14.5 ' + '5.5,16.2 10,11.7 14.5,16.2 16.2,14.5 11.7,10', }), ]), ]); for (const va of vars) { let children; switch (va.type) { case 'color': children = [ $create('.colorview-swatch.config-value', [ va.input = $create('a.color-swatch', { va, tabIndex: 0, onclick: showColorpicker, }), ]), ]; break; case 'checkbox': children = [ $create('span.onoffswitch.config-value', [ va.input = $create('input.slider', { va, type: 'checkbox', onchange: updateVarOnChange, }), $create('span'), ]), ]; break; case 'select': case 'dropdown': case 'image': // TODO: a image picker input? children = [ $create('.select-resizer.config-value', [ va.input = $create('select', { va, onchange: updateVarOnChange, }, va.options.map(o => $create('option', {value: o.name}, o.label))), $create('SVG:svg.svg-icon.select-arrow', $create('SVG:use', {'xlink:href': '#svg-icon-select-arrow'})), ]), ]; 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: va.type, onchange: updateVarOnChange, oninput: updateVarOnInput, onfocus: selectAllOnFocus, }), ]; } resetter = resetter.cloneNode(true); resetter.va = va; resetter.onclick = resetOnClick; elements.push( $create(`label.config-${va.type}`, [ $create('span.config-name', t.breakWord(va.label)), ...children, resetter, ])); va.savedValue = va.value; } } function updateVarOnBlur() { this.value = isDefault(this.va) ? this.va.default : this.va.value; } function updateVarOnChange() { 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) { if (debounced) { event.target.dispatchEvent(new Event('change', {bubbles: true})); } else { debounce(updateVarOnInput, AUTOSAVE_DELAY, event, true); } } 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; if (colorpicker.options.va === va) { colorpicker.setColor(value); } } 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; } if (!prefs.get('config.autosave')) { renderValueState(va); } } } function renderValueState(va) { const el = va.input.closest('label'); el.classList.toggle('dirty', Boolean(va.dirty)); el.classList.toggle('nondefault', !isDefault(va)); $('.config-reset-icon', el).disabled = isDefault(va); } function resetOnClick(event) { event.preventDefault(); this.va.value = null; renderValues([this.va]); onchange({target: this.va.input}); } function showColorpicker(event) { event.preventDefault(); window.off('keydown', messageBox.listeners.key, true); const box = $('#message-box-contents'); colorpicker.show({ va: this.va, color: this.va.value || this.va.default, top: this.getBoundingClientRect().bottom - 5, left: box.getBoundingClientRect().left - 360, guessBrightness: box, callback: onColorChanged, }); } function onColorChanged(newColor) { if (newColor) { this.va.value = newColor; this.va.input.style.backgroundColor = newColor; this.va.input.dispatchEvent(new Event('change', {bubbles: true})); } debounce(restoreEscInDialog); } function restoreEscInDialog() { if (!$('.colorpicker-popup') && messageBox.element) { window.on('keydown', messageBox.listeners.key, true); } } function adjustSizeForPopup(box) { const contents = box.firstElementChild; contents.style = 'max-width: none; max-height: none;'.replace(/;/g, '!important;'); let {offsetWidth: width, offsetHeight: height} = contents; contents.style = ''; const elPicker = document.body.appendChild( $create('.colorpicker-popup', {style: 'display: none!important'})); const PADDING = 50; const MIN_WIDTH = parseFloat(getComputedStyle(elPicker).width) || 350; const MIN_HEIGHT = 250 + PADDING; elPicker.remove(); width = constrain(MIN_WIDTH, 798, width + PADDING); height = constrain(MIN_HEIGHT, 598, height + PADDING); document.body.style.setProperty('min-width', width + 'px', 'important'); document.body.style.setProperty('min-height', height + 'px', 'important'); } function constrain(min, max, value) { return value < min ? min : value > max ? max : value; } }