356 lines
11 KiB
JavaScript
356 lines
11 KiB
JavaScript
/*
|
|
* Copyright (C) 2020 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
|
|
* 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._mutationObserver = new MutationObserver(this._onMutation.bind(this));
|
|
this._observingElement = null;
|
|
this._elementMap = new Map(); // Map([element => observer]...)
|
|
this._elementAncestorMap = new Map(); // Map([element => Set([observer]...))
|
|
}
|
|
|
|
observe(element) {
|
|
if (this._isObserving) { return; }
|
|
|
|
this._observingElement = element;
|
|
this._mutationObserver.observe(element, {
|
|
attributes: true,
|
|
attributeOldValue: true,
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
this._onMutation([{
|
|
type: 'childList',
|
|
target: element.parentNode,
|
|
addedNodes: [element],
|
|
removedNodes: []
|
|
}]);
|
|
}
|
|
|
|
disconnect() {
|
|
if (!this._isObserving) { return; }
|
|
|
|
this._mutationObserver.disconnect();
|
|
this._observingElement = null;
|
|
|
|
for (const observer of this._elementMap.values()) {
|
|
this._removeObserver(observer);
|
|
}
|
|
}
|
|
|
|
async refresh() {
|
|
await this._updateTasks.enqueue(null, {all: true});
|
|
}
|
|
|
|
// Private
|
|
|
|
_onMutation(mutationList) {
|
|
for (const mutation of mutationList) {
|
|
switch (mutation.type) {
|
|
case 'childList':
|
|
this._onChildListMutation(mutation);
|
|
break;
|
|
case 'attributes':
|
|
this._onAttributeMutation(mutation);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
_onChildListMutation({addedNodes, removedNodes, target}) {
|
|
const selector = this._selector;
|
|
const ELEMENT_NODE = Node.ELEMENT_NODE;
|
|
|
|
for (const node of removedNodes) {
|
|
const observers = this._elementAncestorMap.get(node);
|
|
if (typeof observers === 'undefined') { continue; }
|
|
for (const observer of observers) {
|
|
this._removeObserver(observer);
|
|
}
|
|
}
|
|
|
|
for (const node of addedNodes) {
|
|
if (node.nodeType !== ELEMENT_NODE) { continue; }
|
|
if (node.matches(selector)) {
|
|
this._createObserver(node);
|
|
}
|
|
for (const childNode of node.querySelectorAll(selector)) {
|
|
this._createObserver(childNode);
|
|
}
|
|
}
|
|
|
|
if (addedNodes.length !== 0 || addedNodes.length !== 0) {
|
|
const observer = this._elementMap.get(target);
|
|
if (typeof observer !== 'undefined' && observer.hasValue) {
|
|
this._setElementValue(observer.element, observer.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
_onAttributeMutation({target}) {
|
|
const selector = this._selector;
|
|
const observers = this._elementAncestorMap.get(target);
|
|
if (typeof observers !== 'undefined') {
|
|
for (const observer of observers) {
|
|
const element = observer.element;
|
|
if (
|
|
!element.matches(selector) ||
|
|
this._shouldIgnoreElement(element) ||
|
|
this._isObserverStale(observer)
|
|
) {
|
|
this._removeObserver(observer);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (target.matches(selector)) {
|
|
this._createObserver(target);
|
|
}
|
|
}
|
|
|
|
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._elementMap.values()) {
|
|
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) {
|
|
if (this._elementMap.has(element) || this._shouldIgnoreElement(element)) { return; }
|
|
|
|
const metadata = this._createElementMetadata(element);
|
|
const nodeName = element.nodeName.toUpperCase();
|
|
const ancestors = this._getAncestors(element);
|
|
const observer = {
|
|
element,
|
|
ancestors,
|
|
type: (nodeName === 'INPUT' ? element.type : null),
|
|
value: null,
|
|
hasValue: false,
|
|
onChange: null,
|
|
metadata
|
|
};
|
|
observer.onChange = this._onElementChange.bind(this, observer);
|
|
this._elementMap.set(element, observer);
|
|
|
|
element.addEventListener('change', observer.onChange, false);
|
|
|
|
for (const ancestor of ancestors) {
|
|
let observers = this._elementAncestorMap.get(ancestor);
|
|
if (typeof observers === 'undefined') {
|
|
observers = new Set();
|
|
this._elementAncestorMap.set(ancestor, observers);
|
|
}
|
|
observers.add(observer);
|
|
}
|
|
|
|
this._updateTasks.enqueue(observer);
|
|
}
|
|
|
|
_removeObserver(observer) {
|
|
const {element, ancestors} = observer;
|
|
|
|
element.removeEventListener('change', observer.onChange, false);
|
|
observer.onChange = null;
|
|
|
|
this._elementMap.delete(element);
|
|
|
|
for (const ancestor of ancestors) {
|
|
const observers = this._elementAncestorMap.get(ancestor);
|
|
if (typeof observers === 'undefined') { continue; }
|
|
|
|
observers.delete(observer);
|
|
if (observers.size === 0) {
|
|
this._elementAncestorMap.delete(ancestor);
|
|
}
|
|
}
|
|
}
|
|
|
|
_isObserverStale(observer) {
|
|
const {element, type, metadata} = observer;
|
|
const nodeName = element.nodeName.toUpperCase();
|
|
return !(
|
|
type === (nodeName === 'INPUT' ? element.type : null) &&
|
|
this._compareElementMetadata(metadata, this._createElementMetadata(element))
|
|
);
|
|
}
|
|
|
|
_shouldIgnoreElement(element) {
|
|
for (const selector of this._ignoreSelectors) {
|
|
if (element.matches(selector)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_getAncestors(node) {
|
|
const root = this._observingElement;
|
|
const results = [];
|
|
while (true) {
|
|
results.push(node);
|
|
if (node === root) { break; }
|
|
node = node.parentNode;
|
|
if (node === null) { break; }
|
|
}
|
|
return results;
|
|
}
|
|
|
|
_setElementValue(element, value) {
|
|
switch (element.nodeName.toUpperCase()) {
|
|
case 'INPUT':
|
|
switch (element.type) {
|
|
case 'checkbox':
|
|
element.checked = value;
|
|
break;
|
|
case 'text':
|
|
case 'number':
|
|
element.value = value;
|
|
break;
|
|
}
|
|
break;
|
|
case 'TEXTAREA':
|
|
case 'SELECT':
|
|
element.value = value;
|
|
break;
|
|
}
|
|
|
|
const event = new CustomEvent('settingChanged', {detail: {value}});
|
|
element.dispatchEvent(event);
|
|
}
|
|
|
|
_getElementValue(element) {
|
|
switch (element.nodeName.toUpperCase()) {
|
|
case 'INPUT':
|
|
switch (element.type) {
|
|
case 'checkbox':
|
|
return !!element.checked;
|
|
case 'text':
|
|
return `${element.value}`;
|
|
case 'number':
|
|
return DOMDataBinder.convertToNumber(element.value, element);
|
|
}
|
|
break;
|
|
case 'TEXTAREA':
|
|
case 'SELECT':
|
|
return element.value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Utilities
|
|
|
|
static convertToNumber(value, constraints) {
|
|
value = parseFloat(value);
|
|
if (!Number.isFinite(value)) { return 0; }
|
|
|
|
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;
|
|
}
|
|
|
|
static convertToNumberOrNull(value) {
|
|
if (typeof value !== 'number') {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return null;
|
|
}
|
|
value = parseFloat(value);
|
|
}
|
|
return !Number.isNaN(value) ? value : null;
|
|
}
|
|
}
|