Files
tubestation/waterfox/browser/components/sidebar/extlib/Options.js
2025-11-06 14:13:52 +00:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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;