yomichan/ext/js/dom/selector-observer.js

278 lines
9.4 KiB
JavaScript
Raw Permalink Normal View History

/*
* 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/>.
*/
/**
* Class which is used to observe elements matching a selector in specific element.
*/
class SelectorObserver {
/**
* @function OnAddedCallback
* @param {Element} element The element which was added.
* @returns {*} Custom data which is assigned to element and passed to callbacks.
*/
/**
* @function OnRemovedCallback
* @param {Element} element The element which was removed.
* @param {*} data The custom data corresponding to the element.
*/
/**
* @function OnChildrenUpdatedCallback
* @param {Element} element The element which had its children updated.
* @param {*} data The custom data corresponding to the element.
*/
/**
* @function IsStaleCallback
* @param {Element} element The element which had its children updated.
* @param {*} data The custom data corresponding to the element.
* @returns {boolean} Whether or not the data is stale for the element.
*/
/**
* Creates a new instance.
* @param {object} details The configuration for the object.
* @param {string} details.selector A string CSS selector used to find elements.
* @param {?string} details.ignoreSelector A string CSS selector used to filter elements, or `null` for no filtering.
* @param {OnAddedCallback} details.onAdded A function which is invoked for each element that is added that matches the selector.
* @param {?OnRemovedCallback} details.onRemoved A function which is invoked for each element that is removed, or `null`.
* @param {?OnChildrenUpdatedCallback} details.onChildrenUpdated A function which is invoked for each element which has its children updated, or `null`.
* @param {?IsStaleCallback} details.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.
*/
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 {boolean} `true` if an element is being observed, `false` otherwise.
*/
get isObserving() {
return this._observingElement !== null;
}
/**
* Starts DOM mutation observing the target element.
* @param {Element} element The element to observe changes in.
* @param {boolean} [attributes] A boolean for whether or not attribute changes should be observed.
* @throws {Error} An error if element is null.
* @throws {Error} 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;
}
}