/* * 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; } } // Utilities static convertToNumber(value, constraints) { value = parseFloat(value); if (!Number.isFinite(value)) { value = 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; } }