Skip to content

Instantly share code, notes, and snippets.

@SidIcarus
Last active July 1, 2021 19:32
Show Gist options
  • Save SidIcarus/86b8a515a53c5b0af09071858c729ea5 to your computer and use it in GitHub Desktop.
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
/**
* @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