SelectorObserver (#927)
* Create new SelectorObserver class * Update DOMDataBinder to use SelectorObserver * Update names to be more clear * Remove attributeOldValue parameter, clarify attributes parameter * Add documentation
This commit is contained in:
parent
e5ef3fe9c2
commit
642c434829
@ -1235,6 +1235,7 @@
|
|||||||
<script src="/mixed/js/dom-data-binder.js"></script>
|
<script src="/mixed/js/dom-data-binder.js"></script>
|
||||||
<script src="/mixed/js/html-template-collection.js"></script>
|
<script src="/mixed/js/html-template-collection.js"></script>
|
||||||
<script src="/mixed/js/object-property-accessor.js"></script>
|
<script src="/mixed/js/object-property-accessor.js"></script>
|
||||||
|
<script src="/mixed/js/selector-observer.js"></script>
|
||||||
<script src="/mixed/js/task-accumulator.js"></script>
|
<script src="/mixed/js/task-accumulator.js"></script>
|
||||||
<script src="/mixed/js/text-to-speech-audio.js"></script>
|
<script src="/mixed/js/text-to-speech-audio.js"></script>
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* global
|
/* global
|
||||||
|
* SelectorObserver
|
||||||
* TaskAccumulator
|
* TaskAccumulator
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -30,39 +31,22 @@ class DOMDataBinder {
|
|||||||
this._onError = onError;
|
this._onError = onError;
|
||||||
this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
|
this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
|
||||||
this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
|
this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
|
||||||
this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
|
this._selectorObserver = new SelectorObserver({
|
||||||
this._observingElement = null;
|
selector,
|
||||||
this._elementMap = new Map(); // Map([element => observer]...)
|
ignoreSelector: (ignoreSelectors.length > 0 ? ignoreSelectors.join(',') : null),
|
||||||
this._elementAncestorMap = new Map(); // Map([element => Set([observer]...))
|
onAdded: this._createObserver.bind(this),
|
||||||
|
onRemoved: this._removeObserver.bind(this),
|
||||||
|
onChildrenUpdated: this._onObserverChildrenUpdated.bind(this),
|
||||||
|
isStale: this._isObserverStale.bind(this)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(element) {
|
observe(element) {
|
||||||
if (this._isObserving) { return; }
|
this._selectorObserver.observe(element, true);
|
||||||
|
|
||||||
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() {
|
disconnect() {
|
||||||
if (!this._isObserving) { return; }
|
this._selectorObserver.disconnect();
|
||||||
|
|
||||||
this._mutationObserver.disconnect();
|
|
||||||
this._observingElement = null;
|
|
||||||
|
|
||||||
for (const observer of this._elementMap.values()) {
|
|
||||||
this._removeObserver(observer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
@ -71,70 +55,6 @@ class DOMDataBinder {
|
|||||||
|
|
||||||
// Private
|
// 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) {
|
async _onBulkUpdate(tasks) {
|
||||||
let all = false;
|
let all = false;
|
||||||
const targets = [];
|
const targets = [];
|
||||||
@ -150,7 +70,7 @@ class DOMDataBinder {
|
|||||||
}
|
}
|
||||||
if (all) {
|
if (all) {
|
||||||
targets.length = 0;
|
targets.length = 0;
|
||||||
for (const observer of this._elementMap.values()) {
|
for (const observer of this._selectorObserver.datas()) {
|
||||||
targets.push([observer, null]);
|
targets.push([observer, null]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,14 +125,10 @@ class DOMDataBinder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_createObserver(element) {
|
_createObserver(element) {
|
||||||
if (this._elementMap.has(element) || this._shouldIgnoreElement(element)) { return; }
|
|
||||||
|
|
||||||
const metadata = this._createElementMetadata(element);
|
const metadata = this._createElementMetadata(element);
|
||||||
const nodeName = element.nodeName.toUpperCase();
|
const nodeName = element.nodeName.toUpperCase();
|
||||||
const ancestors = this._getAncestors(element);
|
|
||||||
const observer = {
|
const observer = {
|
||||||
element,
|
element,
|
||||||
ancestors,
|
|
||||||
type: (nodeName === 'INPUT' ? element.type : null),
|
type: (nodeName === 'INPUT' ? element.type : null),
|
||||||
value: null,
|
value: null,
|
||||||
hasValue: false,
|
hasValue: false,
|
||||||
@ -220,43 +136,27 @@ class DOMDataBinder {
|
|||||||
metadata
|
metadata
|
||||||
};
|
};
|
||||||
observer.onChange = this._onElementChange.bind(this, observer);
|
observer.onChange = this._onElementChange.bind(this, observer);
|
||||||
this._elementMap.set(element, observer);
|
|
||||||
|
|
||||||
element.addEventListener('change', observer.onChange, false);
|
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);
|
this._updateTasks.enqueue(observer);
|
||||||
|
|
||||||
|
return observer;
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeObserver(observer) {
|
_removeObserver(element, observer) {
|
||||||
const {element, ancestors} = observer;
|
|
||||||
|
|
||||||
element.removeEventListener('change', observer.onChange, false);
|
element.removeEventListener('change', observer.onChange, false);
|
||||||
observer.onChange = null;
|
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) {
|
_onObserverChildrenUpdated(element, observer) {
|
||||||
const {element, type, metadata} = observer;
|
if (observer.hasValue) {
|
||||||
|
this._setElementValue(element, observer.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isObserverStale(element, observer) {
|
||||||
|
const {type, metadata} = observer;
|
||||||
const nodeName = element.nodeName.toUpperCase();
|
const nodeName = element.nodeName.toUpperCase();
|
||||||
return !(
|
return !(
|
||||||
type === (nodeName === 'INPUT' ? element.type : null) &&
|
type === (nodeName === 'INPUT' ? element.type : null) &&
|
||||||
@ -264,27 +164,6 @@ class DOMDataBinder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_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) {
|
_setElementValue(element, value) {
|
||||||
switch (element.nodeName.toUpperCase()) {
|
switch (element.nodeName.toUpperCase()) {
|
||||||
case 'INPUT':
|
case 'INPUT':
|
||||||
|
255
ext/mixed/js/selector-observer.js
Normal file
255
ext/mixed/js/selector-observer.js
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class which is used to observe elements matching a selector in specific element.
|
||||||
|
*/
|
||||||
|
class SelectorObserver {
|
||||||
|
/**
|
||||||
|
* Creates a new instance.
|
||||||
|
* @param selector A string CSS selector used to find elements.
|
||||||
|
* @param ignoreSelector A string CSS selector used to filter elements, or null for no filtering.
|
||||||
|
* @param onAdded A function which is invoked for each element that is added that matches the selector.
|
||||||
|
* The signature is (element) => data.
|
||||||
|
* @param onRemoved A function which is invoked for each element that is removed, or null.
|
||||||
|
* The signature is (element, data) => void.
|
||||||
|
* @param onChildrenUpdated A function which is invoked for each element which has its children updated, or null.
|
||||||
|
* The signature is (element, data) => void.
|
||||||
|
* @param isStale A function which checks if the data is stale for a given element, or null.
|
||||||
|
* If the element is stale, it will be removed and potentially re-added.
|
||||||
|
* The signature is (element, data) => bool.
|
||||||
|
*/
|
||||||
|
constructor({selector, ignoreSelector=null, onAdded=null, onRemoved=null, onChildrenUpdated=null, isStale=null}) {
|
||||||
|
this._selector = selector;
|
||||||
|
this._ignoreSelector = ignoreSelector;
|
||||||
|
this._onAdded = onAdded;
|
||||||
|
this._onRemoved = onRemoved;
|
||||||
|
this._onChildrenUpdated = onChildrenUpdated;
|
||||||
|
this._isStale = isStale;
|
||||||
|
this._observingElement = null;
|
||||||
|
this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
|
||||||
|
this._elementMap = new Map(); // Map([element => observer]...)
|
||||||
|
this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...)
|
||||||
|
this._isObserving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether or not an element is currently being observed.
|
||||||
|
* @returns True if an element is being observed, false otherwise.
|
||||||
|
*/
|
||||||
|
get isObserving() {
|
||||||
|
return this._observingElement !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts DOM mutation observing the target element.
|
||||||
|
* @param element The element to observe changes in.
|
||||||
|
* @param attributes A boolean for whether or not attribute changes should be observed.
|
||||||
|
* @throws An error if element is null.
|
||||||
|
* @throws An error if an element is already being observed.
|
||||||
|
*/
|
||||||
|
observe(element, attributes=false) {
|
||||||
|
if (element === null) {
|
||||||
|
throw new Error('Invalid element');
|
||||||
|
}
|
||||||
|
if (this.isObserving) {
|
||||||
|
throw new Error('Instance is already observing an element');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._observingElement = element;
|
||||||
|
this._mutationObserver.observe(element, {
|
||||||
|
attributes: !!attributes,
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this._onMutation([{
|
||||||
|
type: 'childList',
|
||||||
|
target: element.parentNode,
|
||||||
|
addedNodes: [element],
|
||||||
|
removedNodes: []
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops observing the target element.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (!this.isObserving) { return; }
|
||||||
|
|
||||||
|
this._mutationObserver.disconnect();
|
||||||
|
this._observingElement = null;
|
||||||
|
|
||||||
|
for (const observer of this._elementMap.values()) {
|
||||||
|
this._removeObserver(observer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an iterable list of [element, data] pairs.
|
||||||
|
* @yields A sequence of [element, data] pairs.
|
||||||
|
*/
|
||||||
|
*entries() {
|
||||||
|
for (const [element, {data}] of this._elementMap) {
|
||||||
|
yield [element, data];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an iterable list of data for every element.
|
||||||
|
* @yields A sequence of data values.
|
||||||
|
*/
|
||||||
|
*datas() {
|
||||||
|
for (const {data} of this._elementMap.values()) {
|
||||||
|
yield data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
this._onChildrenUpdated !== null &&
|
||||||
|
(addedNodes.length !== 0 || addedNodes.length !== 0)
|
||||||
|
) {
|
||||||
|
for (let node = target; node !== null; node = node.parentNode) {
|
||||||
|
const observer = this._elementMap.get(node);
|
||||||
|
if (typeof observer !== 'undefined') {
|
||||||
|
this._onObserverChildrenUpdated(observer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_createObserver(element) {
|
||||||
|
if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; }
|
||||||
|
|
||||||
|
const data = this._onAdded(element);
|
||||||
|
const ancestors = this._getAncestors(element);
|
||||||
|
const observer = {element, ancestors, data};
|
||||||
|
|
||||||
|
this._elementMap.set(element, observer);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeObserver(observer) {
|
||||||
|
const {element, ancestors, data} = observer;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._onRemoved !== null) {
|
||||||
|
this._onRemoved(element, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onObserverChildrenUpdated(observer) {
|
||||||
|
this._onChildrenUpdated(observer.element, observer.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isObserverStale(observer) {
|
||||||
|
return (this._isStale !== null && this._isStale(observer.element, observer.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
_shouldIgnoreElement(element) {
|
||||||
|
return (this._ignoreSelector !== null && element.matches(this._ignoreSelector));
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user