aboutsummaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2020-10-17 16:33:11 -0400
committerGitHub <noreply@github.com>2020-10-17 16:33:11 -0400
commit642c434829829857ae4b9654d168438eb13bd2f7 (patch)
tree24cf91b1f4ac4eef8d3fa87fc692a68e51c59cbb /ext
parente5ef3fe9c26a70cce049fa11430f29e503edb3c8 (diff)
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
Diffstat (limited to 'ext')
-rw-r--r--ext/bg/settings.html1
-rw-r--r--ext/mixed/js/dom-data-binder.js163
-rw-r--r--ext/mixed/js/selector-observer.js255
3 files changed, 277 insertions, 142 deletions
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 6c61059a..348caa7e 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -1235,6 +1235,7 @@
<script src="/mixed/js/dom-data-binder.js"></script>
<script src="/mixed/js/html-template-collection.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/text-to-speech-audio.js"></script>
diff --git a/ext/mixed/js/dom-data-binder.js b/ext/mixed/js/dom-data-binder.js
index b33def8f..8b82860d 100644
--- a/ext/mixed/js/dom-data-binder.js
+++ b/ext/mixed/js/dom-data-binder.js
@@ -16,6 +16,7 @@
*/
/* global
+ * SelectorObserver
* TaskAccumulator
*/
@@ -30,39 +31,22 @@ class DOMDataBinder {
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]...))
+ 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) {
- 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: []
- }]);
+ this._selectorObserver.observe(element, true);
}
disconnect() {
- if (!this._isObserving) { return; }
-
- this._mutationObserver.disconnect();
- this._observingElement = null;
-
- for (const observer of this._elementMap.values()) {
- this._removeObserver(observer);
- }
+ this._selectorObserver.disconnect();
}
async refresh() {
@@ -71,70 +55,6 @@ class DOMDataBinder {
// 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 = [];
@@ -150,7 +70,7 @@ class DOMDataBinder {
}
if (all) {
targets.length = 0;
- for (const observer of this._elementMap.values()) {
+ for (const observer of this._selectorObserver.datas()) {
targets.push([observer, null]);
}
}
@@ -205,14 +125,10 @@ class DOMDataBinder {
}
_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,
@@ -220,43 +136,27 @@ class DOMDataBinder {
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;
+ return observer;
+ }
+ _removeObserver(element, 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);
- }
+ _onObserverChildrenUpdated(element, observer) {
+ if (observer.hasValue) {
+ this._setElementValue(element, observer.value);
}
}
- _isObserverStale(observer) {
- const {element, type, metadata} = observer;
+ _isObserverStale(element, observer) {
+ const {type, metadata} = observer;
const nodeName = element.nodeName.toUpperCase();
return !(
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) {
switch (element.nodeName.toUpperCase()) {
case 'INPUT':
diff --git a/ext/mixed/js/selector-observer.js b/ext/mixed/js/selector-observer.js
new file mode 100644
index 00000000..9f2f6c72
--- /dev/null
+++ b/ext/mixed/js/selector-observer.js
@@ -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;
+ }
+}