/* 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(`