aboutsummaryrefslogtreecommitdiff
path: root/ext/mixed/js/dom-data-binder.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/mixed/js/dom-data-binder.js')
-rw-r--r--ext/mixed/js/dom-data-binder.js349
1 files changed, 349 insertions, 0 deletions
diff --git a/ext/mixed/js/dom-data-binder.js b/ext/mixed/js/dom-data-binder.js
new file mode 100644
index 00000000..d46e8087
--- /dev/null
+++ b/ext/mixed/js/dom-data-binder.js
@@ -0,0 +1,349 @@
+/*
+ * 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;
+ }
+ }
+
+ _getElementValue(element) {
+ switch (element.nodeName.toUpperCase()) {
+ case 'INPUT':
+ switch (element.type) {
+ case 'checkbox':
+ return !!element.checked;
+ case 'text':
+ return `${element.value}`;
+ case 'number':
+ return this._getInputNumberValue(element);
+ }
+ break;
+ case 'TEXTAREA':
+ case 'SELECT':
+ return element.value;
+ }
+ return null;
+ }
+
+ _getInputNumberValue(element) {
+ let value = parseFloat(element.value);
+ if (!Number.isFinite(value)) { return 0; }
+
+ let {min, max, step} = element;
+ min = this._stringValueToNumberOrNull(min);
+ max = this._stringValueToNumberOrNull(max);
+ step = this._stringValueToNumberOrNull(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;
+ }
+
+ _stringValueToNumberOrNull(value) {
+ if (typeof value !== 'string' || value.length === 0) {
+ return null;
+ }
+
+ const number = parseFloat(value);
+ return !Number.isNaN(number) ? number : null;
+ }
+}