/* license: The MIT License, Copyright (c) 2016-2020 YUKI "Piro" Hiroshi original: http://github.com/piroor/webextensions-lib-options */ class Options { constructor(configs, { steps, onImporting, onImported, onExporting, onExported } = {}) { this.configs = configs; this.steps = steps || {}; this.$onImporting = onImporting; this.$onImported = onImported; this.$onExporting = onExporting; this.$onExported = onExported; this.uiNodes = new Map(); this.throttleTimers = new Map(); this.onReady = this.onReady.bind(this); this.onConfigChanged = this.onConfigChanged.bind(this) document.addEventListener('DOMContentLoaded', this.onReady); } findUIsForKey(key) { key = this._sanitizeForSelector(key); if (!key) return []; return document.querySelectorAll(`[name="${key}"], #${key}, [data-config-key="${key}"]`); } detectUIType(node) { if (!node) return this.UI_MISSING; if (node.localName == 'textarea') return this.UI_TYPE_TEXT_FIELD; if (node.localName == 'select') return this.UI_TYPE_SELECTBOX; if (node.localName != 'input') return this.UI_TYPE_UNKNOWN; switch (node.type) { case 'text': case 'password': case 'number': case 'color': return this.UI_TYPE_TEXT_FIELD; case 'checkbox': return this.UI_TYPE_CHECKBOX; case 'radio': return this.UI_TYPE_RADIO; default: return this.UI_TYPE_UNKNOWN; } } throttledUpdate(key, uiNode, value) { if (this.throttleTimers.has(key)) clearTimeout(this.throttleTimers.get(key)); uiNode.dataset.configValueUpdating = true; this.throttleTimers.set(key, setTimeout(() => { this.throttleTimers.delete(key); this.configs[key] = this.UIValueToConfigValue(key, value); setTimeout(() => { uiNode.dataset.configValueUpdating = false; }, 50); }, 250)); } UIValueToConfigValue(key, value) { switch (typeof this.configs.$default[key]) { case 'string': return String(value); case 'number': return Number(value); case 'boolean': if (typeof value == 'string') return value != 'false'; else return Boolean(value); default: // object if (typeof value == 'string') return JSON.parse(value || 'null'); else return value; } } configValueToUIValue(value) { if (typeof value == 'object') { value = JSON.stringify(value); if (value == 'null') value = ''; return value; } else return value; } applyLocked(node, key) { const locked = this.configs.$isLocked(key); node.disabled = locked; const selector = node.id && `label[for="${this._sanitizeForSelector(node.id)}"]`; const label = node.closest('label') || (node.id && node.ownerDocument.querySelector(selector)) || node; if (label) label.classList.toggle('locked', locked); } bindToCheckbox(key, node) { node.checked = this.configValueToUIValue(this.configs[key]); node.addEventListener('change', () => { this.throttledUpdate(key, node, node.checked); }); this.applyLocked(node, key); this.addResetMethod(key, node); const nodes = this.uiNodes.get(key) || []; nodes.push(node); this.uiNodes.set(key, nodes); } bindToRadio(key, node) { const group = node.getAttribute('name'); const radios = document.querySelectorAll(`input[name="${this._sanitizeForSelector(group)}"]`); let activated = false; for (const radio of radios) { const nodes = this.uiNodes.get(key) || []; if (nodes.includes(radio)) continue; this.applyLocked(radio, key); nodes.push(radio); this.uiNodes.set(key, nodes); radio.addEventListener('change', () => { if (!activated) return; const stringifiedValue = this.configs[key]; if (stringifiedValue != radio.value) this.throttledUpdate(key, radio, radio.value); }); } const selector = `input[type="radio"][value=${JSON.stringify(String(this.configs[key]))}]`; const chosens = (this.uiNodes.get(key) || []).filter(node => node.matches(selector)); if (chosens && chosens.length > 0) chosens.map(chosen => { chosen.checked = true; }); setTimeout(() => { activated = true; }, 0); } bindToTextField(key, node) { node.value = this.configValueToUIValue(this.configs[key]); node.addEventListener('input', () => { this.throttledUpdate(key, node, node.value); }); this.applyLocked(node, key); this.addResetMethod(key, node); const nodes = this.uiNodes.get(key) || []; nodes.push(node); this.uiNodes.set(key, nodes); } bindToSelectBox(key, node) { node.value = this.configValueToUIValue(this.configs[key]); node.addEventListener('change', () => { this.throttledUpdate(key, node, node.value); }); this.applyLocked(node, key); this.addResetMethod(key, node); const nodes = this.uiNodes.get(key) || []; nodes.push(node); this.uiNodes.set(key, nodes); } addResetMethod(key, node) { node.$reset = () => { this.configs.$reset(key); const value = this.configs.$default[key]; if (this.detectUIType(node) == this.UI_TYPE_CHECKBOX) node.checked = value; else node.value = value; }; } async onReady() { document.removeEventListener('DOMContentLoaded', this.onReady); if (!this.configs || !this.configs.$loaded) throw new Error('you must give configs!'); this.configs.$addObserver(this.onConfigChanged); await this.configs.$loaded; for (const key of Object.keys(this.configs.$default)) { const nodes = this.findUIsForKey(key); if (!nodes.length) continue; for (const node of nodes) { switch (this.detectUIType(node)) { case this.UI_TYPE_CHECKBOX: this.bindToCheckbox(key, node); break; case this.UI_TYPE_RADIO: this.bindToRadio(key, node); break; case this.UI_TYPE_TEXT_FIELD: this.bindToTextField(key, node); break; case this.UI_TYPE_SELECTBOX: this.bindToSelectBox(key, node); break; case this.UI_MISSING: continue; default: throw new Error(`unknown type UI element for ${key}`); } } } } onConfigChanged(key) { const nodes = this.uiNodes.get(key); if (!nodes || !nodes.length) return; for (const node of nodes) { if (node.dataset.configValueUpdating == 'true') continue; if (node.matches('input[type="radio"]')) { node.checked = this.configs[key] == node.value; } else if (node.matches('input[type="checkbox"]')) { node.checked = !!this.configs[key]; } else { node.value = this.configValueToUIValue(this.configs[key]); } this.applyLocked(node, key); } } buildUIForAllConfigs(parent) { parent = parent || document.body; const range = document.createRange(); range.selectNodeContents(parent); range.collapse(false); const rows = []; for (const key of Object.keys(this.configs.$default).sort()) { const value = this.configs.$default[key]; const type = typeof value == 'number' ? 'number' : typeof value == 'boolean' ? 'checkbox' : 'text' ; // To accept decimal values like "1.1", we need to set "step" with decmimal values. // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number const step = type != 'number' ? '' : `step="${this.sanitizeForHTMLText(key in this.steps ? this.steps[key] : String(1.75).replace(/[1-9]/g, '0').replace(/0$/, '1'))}"`; const placeholder = type == 'checkbox' ? '' : `placeholder=${JSON.stringify(this.sanitizeForHTMLText(String(value)))}`; rows.push(` 0 ? 'style="border-top: 1px solid rgba(0, 0, 0, 0.2);"' : ''}> `); } const fragment = range.createContextualFragment(` ${rows.join('')}
`); range.insertNode(fragment); range.detach(); const table = document.getElementById('allconfigs-table'); for (const input of table.querySelectorAll('input')) { const key = input.id.replace(/^allconfigs-field-/, ''); switch (this.detectUIType(input)) { case this.UI_TYPE_CHECKBOX: this.bindToCheckbox(key, input); break; case this.UI_TYPE_TEXT_FIELD: this.bindToTextField(key, input); break; } const button = table.querySelector(`#allconfigs-reset-${this._sanitizeForSelector(key)}`); button.addEventListener('click', () => { input.$reset(); }); button.addEventListener('keyup', event => { if (event.key == 'Enter' || event.key == ' ') input.$reset(); }); } const resetAllButton = document.getElementById('allconfigs-reset-all'); resetAllButton.addEventListener('keydown', event => { if (event.key == 'Enter' || event.key == ' ') this.resetAll(); }); resetAllButton.addEventListener('click', event => { if (event.button == 0) this.resetAll(); }); const exportButton = document.getElementById('allconfigs-export'); exportButton.addEventListener('keydown', event => { if (event.key == 'Enter' || event.key == ' ') this.exportToFile(); }); exportButton.addEventListener('click', event => { if (event.button == 0) this.exportToFile(); }); const importButton = document.getElementById('allconfigs-import'); importButton.addEventListener('keydown', event => { if (event.key == 'Enter' || event.key == ' ') this.importFromFile(); }); importButton.addEventListener('click', event => { if (event.button == 0) this.importFromFile(); }); const fileField = document.getElementById('allconfigs-import-file'); fileField.addEventListener('change', async _event => { const text = await fileField.files.item(0).text(); let values = JSON.parse(text); if (typeof this.$onImporting == 'function') values = await this.$onImporting(values); for (const key of Object.keys(this.configs.$default)) { const value = values[key] !== undefined ? values[key] : this.configs.$default[key]; const changed = value != this.configs[key]; this.configs[key] = value; if (changed) this.onConfigChanged(key); } if (typeof this.$onImported == 'function') await this.$onImported(); }); } sanitizeForHTMLText(text) { return text.replace(/&/g, '&').replace(//g, '>'); } resetAll() { this.configs.$reset(); } importFromFile() { document.getElementById('allconfigs-import-file').click(); } async exportToFile() { let values = {}; for (const key of Object.keys(this.configs.$default).sort()) { const defaultValue = JSON.stringify(this.configs.$default[key]); const currentValue = JSON.stringify(this.configs[key]); if (defaultValue !== currentValue) { values[key] = this.configs[key]; } } if (typeof this.$onExporting == 'function') values = await this.$onExporting(values); // Pretty print the exported JSON, because some major addons // including Stylus and uBlock do that. const exported = JSON.stringify(values, null, 2); const browserInfo = browser.runtime.getBrowserInfo && await browser.runtime.getBrowserInfo(); switch (browserInfo && browserInfo.name) { case 'Thunderbird': window.open(`data:application/json,${encodeURIComponent(exported)}`); break; default: const link = document.getElementById('allconfigs-export-file'); link.href = URL.createObjectURL(new Blob([exported], { type: 'application/json' })); link.click(); break; } if (typeof this.$onExported == 'function') await this.$onExported(); } _sanitizeForSelector(string) { return string.replace(/[:\[\]()]/g, '\\$&'); } }; Options.prototype.UI_TYPE_UNKNOWN = 0; Options.prototype.UI_TYPE_TEXT_FIELD = 1 << 0; Options.prototype.UI_TYPE_CHECKBOX = 1 << 1; Options.prototype.UI_TYPE_RADIO = 1 << 2; Options.prototype.UI_TYPE_SELECTBOX = 1 << 3; export default Options;