spect
Version:
Observe selectors in DOM
306 lines (252 loc) • 11.1 kB
JavaScript
const ELEMENT = 1, SPECT_CLASS = '⬡';
let count = 0, ids = {}, classes = {}, tags = {}, names = {}, animations = {}, setCache = new WeakMap,
doc = document,
hasAnimevent = typeof AnimationEvent !== 'undefined',
style = doc.head.appendChild(doc.createElement('style')),
_proto = Symbol();
// FIXME: use Symbol.species to fix add/map/etc?
function spect (scope, selector, fn) {
// spect`#x`
if (scope && scope.raw) return new SelectorCollection(null, String.raw.apply(null, arguments))
// spect(selector, fn)
if (typeof scope === 'string') return new SelectorCollection(null, scope, selector)
// spect(target, fn)
if (!selector || typeof selector === 'function') {
fn = selector;
let target = scope;
if (!target) target = [];
if (target.nodeType) target = [target];
const set = new SelectorCollection(null, null, fn);
target.forEach(el => set.add(el));
return set
}
return new SelectorCollection(scope, selector, fn)
}
class SelectorCollection extends Array {
#items
#delete
#teardown
#scope
#callback
#selector
#animation
#match
constructor(scope, selector, fn){
// self-call, like splice, map, slice etc. fall back to array
if (typeof scope === 'number') return Array(scope)
super();
this.#items = new WeakMap;
this.#delete = new WeakSet;
this.#teardown = new WeakMap;
this.#scope = scope;
this.#callback = fn;
this[_proto] = Object.getPrototypeOf(this);
// ignore non-selector collections
if (!selector) return
// init existing elements
;(scope || doc).querySelectorAll(selector).forEach(el => { this[_proto].add.call(this, el); });
// if last selector part is simple (id|name|class|tag), followed by classes or attrs - index that
// #a[x][y], [name="e"].x, .x.y, *, a-b-c:x - simple
// a b - not simple
// a.b.c - simple, but filter is .b.c
const rtokens = /(?:#([\w:-]+)|\[\s*name=['"]?([\w:-]+)['"]?\s*\]|\.([\w:-]+)|([\*\w:-]+))(\[[^\]]+\]|\.[\w:-]+)*$/;
this.#selector = selector.split(/\s*,\s*/).map(selector => {
selector = new String(selector);
const match = selector.match(rtokens);
selector.filter = selector; // default filter is itself?
if (!match) return selector // skip indexing (complex one?)
let [str, id, name, cls, tag, filter] = match;
if (id) (ids[selector.id = id] = ids[id] || []).push(this);
else if (name) (names[selector.name = name] = names[name] || []).push(this);
else if (cls) (classes[selector.class = cls] = classes[cls] || []).push(this);
else if (tag) (selector.tag = tag = tag.toUpperCase(), tags[tag] = tags[tag] || []).push(this);
if (filter) selector.filter = selector.slice(0, match.index) + selector.slice(-filter.length);
// `match.index` === 0 means selector is simple and need no match check
else if (!match.index) delete selector.filter;
return selector
});
// if extra match (filtering) is needed
this.#match = this.#selector.some(sel => sel.filter);
// complex selectors are handled via anim events (technique from insertionQuery).
// Cases:
// - dynamically added attributes so that existing nodes match (we don't observe attribs in mutation obserever)
// - complex selectors, including `*` - we avoid > O(c) sync mutations check
// NOTE: only connected scope supports anim observer
if (!hasAnimevent || !this.#selector.every(sel => sel.tag && !sel.filter)) {
let anim = animations[this.#selector];
if (!anim) {
const { sheet } = style, { cssRules } = sheet;
anim = animations[this.#selector] = [];
anim.id = `${SPECT_CLASS}-${(count++).toString(36)}`;
sheet.insertRule(`@keyframes ${ anim.id }{}`, cssRules.length);
sheet.insertRule(`${ this.#selector.map(sel => sel + `:not(.${ anim.id })`) }{animation:${ anim.id }}`, cssRules.length);
sheet.insertRule(`.${ anim.id }{animation:${ anim.id }}`, cssRules.length);
sheet.insertRule(`${ this.#selector.map(sel => sel + `.${ anim.id }`) }{animation:unset;animation:revert}`, cssRules.length);
anim.rules = [].slice.call(cssRules, -4);
anim.onanim = e => {
if (e.animationName !== anim.id) return
e.stopPropagation();
e.preventDefault();
let {target} = e;
if (!target.classList.contains(anim.id)) {
target.classList.add(anim.id);
anim.forEach(set => set[_proto].add.call(set, target, false));
}
else {
target.classList.remove(anim.id);
anim.forEach(set => set.delete(target));
}
};
doc.addEventListener('animationstart', anim.onanim, true);
}
this.#animation = anim.id;
anim.push(this);
}
}
add(el, check=this.#match) {
if (!el) return
// ignore existing
if (this.#items.has(el)) return
// ignore not-matching
if (check) if (!el.matches(this.#selector)) return
// ignore out-of-scope
if (this.#scope && (this.#scope === el || !this.#scope.contains(el))) return
// track collection
this.push(el);
this.#items.set(el, [el.id, el.name]);
if (el.name) this[el.name] = el;
if (el.id) this[el.id] = el;
// cancel planned delete
if (this.#delete.has(el)) this.#delete.delete(el);
// ignore existing items
if (setCache.has(el) && setCache.get(el).has(this)) return
// FIXME: name/id ref obsever - seems like overkill, waiting for real-case demand
// NOTE: this does not hook props added after
// if ((el.name || el.id)) {
// if (!el[_observer]) {
// let name = el.name, id = el.id
// // id/name mutation observer tracks refs and handles unmatch
// ;(el[_observer] = new MutationObserver(records => {
// if (name && (el.name !== name)) if (names[name]) names[name].forEach(c => (delete c[name], c.delete(el)))
// if (name = el.name) if (names[name]) names[name].forEach(c => c.add(el))
// if (id && (el.id !== id)) if (ids[id]) ids[id].forEach(c => (delete c[id], c.delete(el)))
// if (id = el.id) if (ids[id]) ids[id].forEach(c => c.add(el))
// }))
// .observe(el, {attributes: true, attributeFilter: ['name', 'id']})
// }
// }
// enable item
if (!setCache.has(el)) setCache.set(el, new Set);
// mark element
setCache.get(el).add(this);
el.classList.add(SPECT_CLASS);
// notify
this.#teardown.set(el, this.#callback?.(el));
}
delete(el, immediate = false) {
if (!this.#items.has(el)) return
// remove element from list sync
if (this.length) this.splice(this.indexOf(el >>> 0, 1), 1);
const [id, name] = this.#items.get(el);
if (name) delete this[name];
if (id) delete this[id];
this.#items.delete(el);
// plan destroy async (can be re-added)
this.#delete.add(el);
const del = () => {
if (!this.#delete.has(el) || !this.#items) return
this.#delete.delete(el);
if (!setCache.has(el)) return
const teardown = this.#teardown.get(el);
if (teardown) {
if (teardown.call) teardown(el);
else if (teardown.then) teardown.then(fn => fn && fn.call && fn());
}
this.#teardown.delete(el);
setCache.get(el).delete(this);
if (!setCache.get(el).size) {
setCache.delete(el);
el.classList.remove(SPECT_CLASS);
// if (el[_observer]) {
// el[_observer].disconnect()
// delete el[_observer]
// }
}
};
if (immediate) del();
else requestAnimationFrame(del);
}
item(n) { return n < 0 ? this[this.length + n] : this[n] }
namedItem(name) { return this[name] }
has(item) { return this.#items.has(item) }
dispose() {
if (this.#selector) {
this.#selector.forEach(({id, class:cls, name, tag}) => {
id && ids[id].splice(ids[id].indexOf(this) >>> 0, 1);
name && names[name].splice(names[name].indexOf(this) >>> 0, 1);
cls && classes[cls].splice(classes[cls].indexOf(this) >>> 0, 1);
tag && tags[tag].splice(tags[tag].indexOf(this) >>> 0, 1);
});
}
if (this.#animation) {
const anim = animations[this.#selector];
anim.splice(anim.indexOf(this) >>> 0, 1);
if (!anim.length) {
doc.removeEventListener('animationstart', anim.onanim);
delete animations[this.#selector];
if (anim.rules) anim.rules.forEach(rule => {
let idx = [].indexOf.call(style.sheet.cssRules, rule);
if (~idx) style.sheet.deleteRule(idx);
});
}
}
let els = [...this];
this.length = 0;
els.forEach(el => this.delete(el, true));
}
[Symbol.dispose||=Symbol('dispose')]() { return this.dispose }
}
const queryAdd = (targets, sets, check) => {
if (!sets || !targets) return
// HTMLCollection has only iterable method
;[].forEach.call(targets, target => sets.forEach(set => set[_proto].add.call(set, target, check))
);
},
queryDelete = target => [target.classList.contains(SPECT_CLASS) ? target : null, ...target.getElementsByClassName(SPECT_CLASS)]
.forEach(node => setCache.has(node) && setCache.get(node).forEach(set => set.delete(node)))
;(new MutationObserver((list) => {
for (let mutation of list) {
let { addedNodes, removedNodes, target } = mutation;
// fallback for no-animevents env (like SSR)
// WARN: O(n*m) or worse performance (insignificant for small docs)
if (!hasAnimevent) {
queryDelete(target);
for (let sel in animations) queryAdd([target, ...target.querySelectorAll(sel)], animations[sel], true);
}
removedNodes.forEach(target => target.nodeType === ELEMENT && queryDelete(target));
addedNodes.forEach(target => {
if (target.nodeType !== ELEMENT) return
// selector-set optimization:
// instead of walking all registered selectors for each node, we detect which selectors are applicable for the node
if (target.id && ids[target.id]) queryAdd([target], ids[target.id]);
if (target.name && names[target.name]) queryAdd([target], names[target.name]);
if (target.className) target.classList.forEach(cls => queryAdd([target], classes[cls]));
if (tags[target.tagName]) queryAdd([target], tags[target.tagName]);
// detect children against tables
// FIXME: this can be O(n)
if (target.hasChildNodes()) {
// NOTE: <a> and other inlines may not have `getElementById`
if (target.getElementById) for (let id in ids) queryAdd(target.getElementById(id), ids[id]);
for (let name in names) queryAdd(target.getElementsByName(name), names[name]);
for (let cls in classes) queryAdd(target.getElementsByClassName(cls), classes[cls]);
for (let tag in tags) queryAdd(target.getElementsByTagName(tag), tags[tag]);
}
});
}
}))
.observe(doc, {
childList: true,
subtree: true,
attributes: !hasAnimevent
});
export { SelectorCollection, spect as default };