421 lines
14 KiB
JavaScript
421 lines
14 KiB
JavaScript
/*
|
|
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(`
|
|
<tr ${rows.length > 0 ? 'style="border-top: 1px solid rgba(0, 0, 0, 0.2);"' : ''}>
|
|
<td style="width: 45%; word-break: break-all;">
|
|
<label for="allconfigs-field-${this.sanitizeForHTMLText(key)}">${this.sanitizeForHTMLText(key)}</label>
|
|
</td>
|
|
<td style="width: 35%;">
|
|
<input id="allconfigs-field-${this.sanitizeForHTMLText(key)}"
|
|
type="${type}"
|
|
${type != 'checkbox' && type != 'radio' ? 'style="width: 100%;"' : ''}
|
|
${step}
|
|
${placeholder}>
|
|
</td>
|
|
<td>
|
|
<button id="allconfigs-reset-${this.sanitizeForHTMLText(key)}">Reset</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
}
|
|
const fragment = range.createContextualFragment(`
|
|
<table id="allconfigs-table"
|
|
style="border-collapse: collapse">
|
|
<tbody>${rows.join('')}</tbody>
|
|
</table>
|
|
<div>
|
|
<button id="allconfigs-reset-all">Reset All</button>
|
|
<button id="allconfigs-export">Export</button>
|
|
<a id="allconfigs-export-file"
|
|
type="application/json"
|
|
download="configs-${browser.runtime.id}.json"
|
|
style="display:none"></a>
|
|
<button id="allconfigs-import">Import</button>
|
|
<input id="allconfigs-import-file"
|
|
type="file"
|
|
accept="application/json"
|
|
style="display:none">
|
|
</div>
|
|
`);
|
|
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, '<').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;
|