yomichan/ext/js/dom/dom-data-binder.js

240 lines
7.6 KiB
JavaScript
Raw Normal View History

/*
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* global
* SelectorObserver
* TaskAccumulator
*/
class DOMDataBinder {
constructor({selector, ignoreSelectors=[], createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) {
this._selector = selector;
this._ignoreSelectors = ignoreSelectors;
this._createElementMetadata = createElementMetadata;
this._compareElementMetadata = compareElementMetadata;
this._getValues = getValues;
this._setValues = setValues;
this._onError = onError;
this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
this._selectorObserver = new SelectorObserver({
selector,
ignoreSelector: (ignoreSelectors.length > 0 ? ignoreSelectors.join(',') : null),
onAdded: this._createObserver.bind(this),
onRemoved: this._removeObserver.bind(this),
onChildrenUpdated: this._onObserverChildrenUpdated.bind(this),
isStale: this._isObserverStale.bind(this)
});
}
observe(element) {
this._selectorObserver.observe(element, true);
}
disconnect() {
this._selectorObserver.disconnect();
}
async refresh() {
await this._updateTasks.enqueue(null, {all: true});
}
// Private
async _onBulkUpdate(tasks) {
let all = false;
const targets = [];
for (const [observer, task] of tasks) {
if (observer === null) {
if (task.data.all) {
all = true;
break;
}
} else {
targets.push([observer, task]);
}
}
if (all) {
targets.length = 0;
for (const observer of this._selectorObserver.datas()) {
targets.push([observer, null]);
}
}
const args = targets.map(([observer]) => ({
element: observer.element,
metadata: observer.metadata
}));
const responses = await this._getValues(args);
this._applyValues(targets, responses, true);
}
async _onBulkAssign(tasks) {
const targets = tasks;
const args = targets.map(([observer, task]) => ({
element: observer.element,
metadata: observer.metadata,
value: task.data.value
}));
const responses = await this._setValues(args);
this._applyValues(targets, responses, false);
}
_onElementChange(observer) {
const value = this._getElementValue(observer.element);
observer.value = value;
observer.hasValue = true;
this._assignTasks.enqueue(observer, {value});
}
_applyValues(targets, response, ignoreStale) {
if (!Array.isArray(response)) { return; }
for (let i = 0, ii = targets.length; i < ii; ++i) {
const [observer, task] = targets[i];
const {error, result} = response[i];
const stale = (task !== null && task.stale);
if (error) {
if (typeof this._onError === 'function') {
this._onError(error, stale, observer.element, observer.metadata);
}
continue;
}
if (stale && !ignoreStale) { continue; }
observer.value = result;
observer.hasValue = true;
this._setElementValue(observer.element, result);
}
}
_createObserver(element) {
const metadata = this._createElementMetadata(element);
const observer = {
element,
type: this._getNormalizedElementType(element),
value: null,
hasValue: false,
onChange: null,
metadata
};
observer.onChange = this._onElementChange.bind(this, observer);
element.addEventListener('change', observer.onChange, false);
this._updateTasks.enqueue(observer);
return observer;
}
_removeObserver(element, observer) {
element.removeEventListener('change', observer.onChange, false);
observer.onChange = null;
}
_onObserverChildrenUpdated(element, observer) {
if (observer.hasValue) {
this._setElementValue(element, observer.value);
}
}
_isObserverStale(element, observer) {
const {type, metadata} = observer;
return !(
type === this._getNormalizedElementType(element) &&
this._compareElementMetadata(metadata, this._createElementMetadata(element))
);
}
_setElementValue(element, value) {
switch (this._getNormalizedElementType(element)) {
case 'checkbox':
element.checked = value;
break;
case 'text':
case 'number':
case 'textarea':
case 'select':
element.value = value;
break;
}
const event = new CustomEvent('settingChanged', {detail: {value}});
element.dispatchEvent(event);
}
_getElementValue(element) {
switch (this._getNormalizedElementType(element)) {
case 'checkbox':
return !!element.checked;
case 'text':
return `${element.value}`;
case 'number':
return DOMDataBinder.convertToNumber(element.value, element);
case 'textarea':
case 'select':
return element.value;
}
return null;
}
_getNormalizedElementType(element) {
switch (element.nodeName.toUpperCase()) {
case 'INPUT':
{
let {type} = element;
if (type === 'password') { type = 'text'; }
return type;
}
case 'TEXTAREA':
return 'textarea';
case 'SELECT':
return 'select';
default:
return null;
}
}
2020-10-08 21:38:18 +00:00
// Utilities
static convertToNumber(value, constraints) {
value = parseFloat(value);
if (!Number.isFinite(value)) { value = 0; }
2020-10-08 21:38:18 +00:00
let {min, max, step} = constraints;
min = DOMDataBinder.convertToNumberOrNull(min);
max = DOMDataBinder.convertToNumberOrNull(max);
step = DOMDataBinder.convertToNumberOrNull(step);
if (typeof min === 'number') { value = Math.max(value, min); }
if (typeof max === 'number') { value = Math.min(value, max); }
if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; }
return value;
}
2020-10-08 21:38:18 +00:00
static convertToNumberOrNull(value) {
if (typeof value !== 'number') {
if (typeof value !== 'string' || value.length === 0) {
return null;
}
value = parseFloat(value);
}
2020-10-08 21:38:18 +00:00
return !Number.isNaN(value) ? value : null;
}
}