Last active
July 1, 2021 19:32
-
-
Save SidIcarus/86b8a515a53c5b0af09071858c729ea5 to your computer and use it in GitHub Desktop.
JavaScript library to observe style changes on any DOM element. For an observed element, on every computed style change returns a difference object. Allows to be imported as a module & for optional filtering by any given styles. Slightly more performant then Sauron-Style
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @param {Element} node | |
* @returns {node is HTMLLinkElement} | |
*/ | |
function isLink (node) { // @ts-ignore | |
return node.tagName === 'LINK' && node.rel === 'stylesheet' | |
} | |
/** | |
* @param {Element} node | |
* @returns {node is HTMLStyleElement} | |
*/ | |
function isStyle (node) { | |
return node.tagName === 'STYLE' | |
} | |
/** | |
* @param {NodeList} list | |
* @returns {HTMLElement[]} | |
*/ | |
function getArray (list) { | |
return Array.prototype.filter.call(list, function (node) { | |
return isStyle(node) || isLink(node) | |
}) | |
} | |
export class DocumentObserver { | |
constructor () { | |
this.nextId = 0 | |
/** @type {{ [id: number]: Function }} */ | |
this.listeners = {} | |
this.observer = new window.MutationObserver( | |
(mutations) => mutations.forEach(this.observe.bind(this)) | |
) | |
this.observer.observe(window.document, { | |
attributes: true, | |
attributeFilter: ['class'], | |
childList: true, | |
subtree: true | |
}) | |
} | |
/** @param {Function} listener */ | |
addListener (listener) { | |
this.listeners[this.nextId] = listener | |
return this.nextId++ | |
} | |
/** @param {number} id */ | |
removeListener (id) { | |
delete this.listeners[id] | |
} | |
invokeAll () { | |
for (const id in this.listeners) this.listeners[id]() | |
} | |
/** @param {MutationRecord} mutation */ | |
observe (mutation) { | |
if (mutation.type === 'childList') this.checkElements(mutation) | |
else if (mutation.type === 'attributes') this.invokeAll() | |
} | |
destroy () { | |
this.observer.disconnect() | |
} | |
/** @param {MutationRecord} mutation */ | |
checkElements (mutation) { | |
const added = getArray(mutation.addedNodes) | |
const removed = getArray(mutation.removedNodes) | |
if (added.length) { | |
added.forEach((node) => { | |
if (isLink(node)) node.addEventListener('load', () => this.invokeAll()) | |
if (isStyle(node)) this.invokeAll() | |
}) | |
} | |
if (removed.length) this.invokeAll() | |
} | |
} | |
/** @type {DocumentObserver|undefined} */ | |
let instance | |
/** @returns {DocumentObserver} */ | |
export function getDocumentObserver () { | |
if (!instance) instance = new DocumentObserver() | |
return instance | |
} | |
/** | |
* If no more listeners are present, clean it up! Otherwise, does nothing | |
*/ | |
export function destroyDocumentObserver () { | |
if (Object.keys(instance.listeners).length > 0) return | |
instance.destroy() | |
instance = undefined | |
} | |
/** @typedef {{ [key: string]: { cur: any, prev: any } }} Diff */ | |
/** | |
* @template {Object} T | |
* @param {T} a | |
* @param {T} b | |
*/ | |
export function getDiff (a, b) { | |
/** @type {Diff} */ | |
const result = {} | |
for (const key in b) { | |
const cur = b[key] | |
const prev = a[key] | |
if (cur !== prev) result[key] = { cur, prev } | |
} | |
return result | |
} | |
/** | |
* @template T | |
* @param {T} obj | |
* @param {(keyof T)[]} [keys] strings should be keyof T | |
* @returns {T | Partial<T> | {}} | |
*/ | |
export function getCopy (obj, keys) { | |
if (obj == null) return {} | |
const doFilter = Array.isArray(keys) | |
const result = {} | |
for (const key in obj) { | |
if (doFilter && !keys.includes(key)) continue | |
result[key] = obj[key] | |
} | |
return result | |
} | |
/** | |
* Based on {@link https://github.com/oleggromov/sauron-style Sauron-Style} | |
* | |
* Usage: | |
* ``` | |
* import StyleObserver from '' | |
* | |
* // if you plan on keeping it around, remember to return the observer & | |
* // destroy it later on! | |
* observeStyles (el) { | |
* const observer = new StyleObserver(el) | |
* // optionally filter by certain styles | |
* observer.filterBy(['keyof', 'CSSStyleDeclaration', 'i.e', 'width']) | |
* observer.subscribe(diff => { | |
* // ?? | |
* // profit | |
* }) | |
* } | |
* ``` | |
*/ | |
export default class StyleObserver { | |
/** @param {HTMLElement} node */ | |
constructor (node) { | |
/** @type {Function|null} */ | |
this.subscriber = null | |
/** @type {(keyof CSSStyleDeclaration)[] } */ | |
this.filter = null | |
this.node = node | |
this.checkDiff = this.checkDiff.bind(this) | |
this.mutationObserver = new window.MutationObserver(this.checkDiff) | |
this.mutationObserver.observe(this.node, { | |
attributes: true, | |
attributeFilter: ['style', 'class'] | |
}) | |
this.computedStyle = window.getComputedStyle(this.node) | |
/** Copy of the computed style */ | |
this.style = this.getStyle() | |
this.documentObserver = getDocumentObserver() | |
this.listenerId = this.documentObserver.addListener(this.checkDiff) | |
} | |
destroy () { | |
this.filterBy = null | |
this.node = null | |
this.subscriber = null | |
this.mutationObserver.disconnect() | |
this.documentObserver.removeListener(this.listenerId) | |
destroyDocumentObserver() | |
} | |
/** | |
* @param {(keyof CSSStyleDeclaration)[] | null} styles | |
*/ | |
filterBy (styles) { | |
this.filter = styles | |
} | |
/** @param {(diff: Diff) => void} fn */ | |
subscribe (fn) { | |
this.subscriber = fn | |
} | |
checkDiff () { | |
const newStyle = this.getStyle() | |
const diff = getDiff(this.style, newStyle) | |
if (!Object.keys(diff).length) return | |
if (this.subscriber) this.subscriber(diff) | |
this.style = newStyle | |
} | |
getStyle () { | |
return getCopy(this.computedStyle, this.filter) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment