/*
* Copyright (C) 2023-2024 Yomitan Authors
* 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 .
*/
import {TaskAccumulator} from '../general/task-accumulator.js';
import {convertElementValueToNumber} from './document-util.js';
import {SelectorObserver} from './selector-observer.js';
/**
* @template [T=unknown]
*/
export class DOMDataBinder {
/**
* @param {string} selector
* @param {import('dom-data-binder').CreateElementMetadataCallback} createElementMetadata
* @param {import('dom-data-binder').CompareElementMetadataCallback} compareElementMetadata
* @param {import('dom-data-binder').GetValuesCallback} getValues
* @param {import('dom-data-binder').SetValuesCallback} setValues
* @param {import('dom-data-binder').OnErrorCallback|null} [onError]
*/
constructor(selector, createElementMetadata, compareElementMetadata, getValues, setValues, onError = null) {
/** @type {string} */
this._selector = selector;
/** @type {import('dom-data-binder').CreateElementMetadataCallback} */
this._createElementMetadata = createElementMetadata;
/** @type {import('dom-data-binder').CompareElementMetadataCallback} */
this._compareElementMetadata = compareElementMetadata;
/** @type {import('dom-data-binder').GetValuesCallback} */
this._getValues = getValues;
/** @type {import('dom-data-binder').SetValuesCallback} */
this._setValues = setValues;
/** @type {?import('dom-data-binder').OnErrorCallback} */
this._onError = onError;
/** @type {TaskAccumulator, import('dom-data-binder').UpdateTaskValue>} */
this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
/** @type {TaskAccumulator, import('dom-data-binder').AssignTaskValue>} */
this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
/** @type {SelectorObserver>} */
this._selectorObserver = new SelectorObserver({
selector,
ignoreSelector: null,
onAdded: this._createObserver.bind(this),
onRemoved: this._removeObserver.bind(this),
onChildrenUpdated: this._onObserverChildrenUpdated.bind(this),
isStale: this._isObserverStale.bind(this)
});
}
/**
* @param {Element} element
*/
observe(element) {
this._selectorObserver.observe(element, true);
}
/** */
disconnect() {
this._selectorObserver.disconnect();
}
/** */
async refresh() {
await this._updateTasks.enqueue(null, {all: true});
}
// Private
/**
* @param {import('dom-data-binder').UpdateTask[]} tasks
*/
async _onBulkUpdate(tasks) {
let all = false;
/** @type {import('dom-data-binder').ApplyTarget[]} */
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);
}
/**
* @param {import('dom-data-binder').AssignTask[]} tasks
*/
async _onBulkAssign(tasks) {
/** @type {import('dom-data-binder').ApplyTarget[]} */
const targets = [];
const args = [];
for (const [observer, task] of tasks) {
if (observer === null) { continue; }
args.push({
element: observer.element,
metadata: observer.metadata,
value: task.data.value
});
targets.push([observer, task]);
}
const responses = await this._setValues(args);
this._applyValues(targets, responses, false);
}
/**
* @param {import('dom-data-binder').ElementObserver} observer
*/
_onElementChange(observer) {
const value = this._getElementValue(observer.element);
observer.value = value;
observer.hasValue = true;
void this._assignTasks.enqueue(observer, {value});
}
/**
* @param {import('dom-data-binder').ApplyTarget[]} targets
* @param {import('dom-data-binder').TaskResult[]} response
* @param {boolean} ignoreStale
*/
_applyValues(targets, response, ignoreStale) {
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);
}
}
/**
* @param {Element} element
* @returns {import('dom-data-binder').ElementObserver|undefined}
*/
_createObserver(element) {
const metadata = this._createElementMetadata(element);
if (typeof metadata === 'undefined') { return void 0; }
/** @type {import('dom-data-binder').ElementObserver} */
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);
void this._updateTasks.enqueue(observer, {all: false});
return observer;
}
/**
* @param {Element} element
* @param {import('dom-data-binder').ElementObserver} observer
*/
_removeObserver(element, observer) {
if (observer.onChange === null) { return; }
element.removeEventListener('change', observer.onChange, false);
observer.onChange = null;
}
/**
* @param {Element} element
* @param {import('dom-data-binder').ElementObserver} observer
*/
_onObserverChildrenUpdated(element, observer) {
if (observer.hasValue) {
this._setElementValue(element, observer.value);
}
}
/**
* @param {Element} element
* @param {import('dom-data-binder').ElementObserver} observer
* @returns {boolean}
*/
_isObserverStale(element, observer) {
const {type, metadata} = observer;
if (type !== this._getNormalizedElementType(element)) { return false; }
const newMetadata = this._createElementMetadata(element);
return typeof newMetadata === 'undefined' || !this._compareElementMetadata(metadata, newMetadata);
}
/**
* @param {Element} element
* @param {unknown} value
*/
_setElementValue(element, value) {
switch (this._getNormalizedElementType(element)) {
case 'checkbox':
/** @type {HTMLInputElement} */ (element).checked = typeof value === 'boolean' && value;
break;
case 'text':
case 'number':
case 'textarea':
case 'select':
/** @type {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} */ (element).value = typeof value === 'string' ? value : `${value}`;
break;
}
/** @type {number|string|boolean} */
let safeValue;
switch (typeof value) {
case 'number':
case 'string':
case 'boolean':
safeValue = value;
break;
default:
safeValue = `${value}`;
break;
}
/** @type {import('dom-data-binder').SettingChangedEvent} */
const event = new CustomEvent('settingChanged', {detail: {value: safeValue}});
element.dispatchEvent(event);
}
/**
* @param {Element} element
* @returns {boolean|string|number|null}
*/
_getElementValue(element) {
switch (this._getNormalizedElementType(element)) {
case 'checkbox':
return !!(/** @type {HTMLInputElement} */ (element).checked);
case 'text':
return `${/** @type {HTMLInputElement} */ (element).value}`;
case 'number':
return convertElementValueToNumber(/** @type {HTMLInputElement} */ (element).value, /** @type {HTMLInputElement} */ (element));
case 'textarea':
return /** @type {HTMLTextAreaElement} */ (element).value;
case 'select':
return /** @type {HTMLSelectElement} */ (element).value;
}
return null;
}
/**
* @param {Element} element
* @returns {import('dom-data-binder').NormalizedElementType}
*/
_getNormalizedElementType(element) {
switch (element.nodeName.toUpperCase()) {
case 'INPUT':
{
const {type} = /** @type {HTMLInputElement} */ (element);
switch (type) {
case 'text':
case 'password':
return 'text';
case 'number':
case 'checkbox':
return type;
}
break;
}
case 'TEXTAREA':
return 'textarea';
case 'SELECT':
return 'select';
}
return null;
}
}