import { getDocument } from "ssr-window"; import { getAttr } from "../../utils/getAttr"; /** * Singleton. Наблюдатель, использующий `MutationObserver` для фиксирования изменений в DOM-дереве и вызывающий специальный метод коллекции `init`, чтобы создать новые вызовы для появившихся элементов, соответствующих селектору коллекции. По умолчанию подключается в самую последнюю очередь. * По умолчанию `Observer` не реагирует на изменения в следующих тегах: `iframe`, `script`, `svg`, `ymaps`, `object`, `img`, `template`, `picture`. Эти исключения определяются в параметре `observerExcludedTags` для повышения производительности. * @module Observer * @param cfg{Object} объект пользовательских настроек * @param cfg.observerExcludedTags{Array} массив исключаемых из наблюдения тегов * @param cfg.observerCfg{MutationObserverInit} конфигурация наблюдателя * @param cfg.excludedSelector{String} селектор для элементов-исключений * @param cfg.includedSelector{String} селектор для элементов-включений (приоритетнее над исключениями) * @param cfg.target{HTMLElement|null} наблюдаемый элемент * @example * import { Core, Observer } from "@delement/ui/plugins" * * new Core({ * ObserverPlugin: () => new Observer({ * observerExcludedTags: [ "iframe", "script", "svg", "object", "img", "template", "picture" ] * }) * }) */ export class Observer { static #instance: Observer | null = null; /** * Конфигурация по умолчанию * @type {{observerExcludedTags, isDebug: boolean, excludedSelector: string, includedSelector: string, observerCfg: {subtree: boolean, attributes: boolean, childList: boolean, characterData: boolean, attributeOldValue: boolean, characterDataOldValue: boolean}}} */ static defaultCfg = { observerExcludedTags: [ "iframe", "script", "svg", "ymaps", "object", "img", "template", "picture" ], observerCfg: { attributes: false, childList: true, subtree: true, characterData: false, attributeOldValue: false, characterDataOldValue: false, }, excludedSelector: "[data-js-ObserverExclude]", includedSelector: "[data-js-ObserverInclude]", isDebug: false, }; /** * Хранилище callback-функций классов * @type {Object} */ static callbacks = new Map void>(); /** * Присвоение в глобальный namespace внутри Core * @type {boolean} */ static isApp = true; /** * Получает наблюдаемый элемент (по умолчанию body документа) * @returns {HTMLElement|null} наблюдаемый элемент */ static getTarget() { return getDocument().body; } observerExcludedTags: string[]; observerCfg: MutationObserverInit; excludedSelector: string; includedSelector: string; isDebug: boolean; observer: MutationObserver; isConnected: boolean; constructor(cfg: Partial = {}) { if (Observer.#instance) { return Observer.#instance; } const { observerExcludedTags, observerCfg, excludedSelector, includedSelector, isDebug, target = Observer.getTarget(), } = { ...Observer.defaultCfg, ...cfg, }; this.observerExcludedTags = observerExcludedTags; this.observerCfg = observerCfg; this.excludedSelector = excludedSelector; this.includedSelector = includedSelector; this.isDebug = isDebug; this.observer = new MutationObserver(this.#collectionObserveCallback.bind(this)); this.isConnected = false; if (Observer.callbacks.size && target) { this.connect(target, this.observerCfg); } Observer.#instance = this; } /** * Включает наблюдение за изменениями в DOM * @param target{HTMLElement|null=} наблюдаемый элемент, по умолчанию body * @param cfg{MutationObserverInit=} конфигурация наблюдателя */ connect(target: HTMLElement | null = Observer.getTarget(), cfg: MutationObserverInit = Observer.defaultCfg.observerCfg) { if (!this.isConnected && target) { this.observer.observe(target, cfg); this.isConnected = true; if (this.isDebug) { console.debug("[Observer] connected with config and callbacks: ", cfg, Observer.callbacks); } } } /** * Управляет состоянием видимости для наблюдателя у конкретного элемента * @param el{Element} DOM-элемент * @param isObservable{Boolean=} состояние видимости (true = наблюдаемый, false = исключённый) */ static setElObservableState(el: Element, isObservable = true) { const { includedSelector, excludedSelector } = Observer.defaultCfg; const included = getAttr(includedSelector); const excluded = getAttr(excludedSelector); el.setAttribute(isObservable ? included : excluded, ""); el.removeAttribute(isObservable ? excluded : included); } /** * Проверяет включение тега в массив исключенных тегов * @param tagName * @returns {boolean} */ #isTagExcluded(tagName: string) { return !!this.observerExcludedTags.includes(tagName.toLowerCase()); } /** * Проверяет массив элементов на наличие исключенных тегов * @param nodes * @returns {boolean} */ #isExcludedEls(nodes: NodeList = [] as unknown as NodeList) { return !!nodes.length && [ ...nodes ].every((el) => { const { nodeType, tagName } = el as Element; return nodeType === 1 && !(el as Element).closest(this.includedSelector) && this.#isTagExcluded(tagName); }); } /** * Проверка на исключение из наблюдения * @param mutationList{MutationRecord} запись из наблюдателя * @returns {boolean} */ #isExcludedMutationRecord(mutationList: MutationRecord[]) { return [ ...mutationList ].some(({ target, addedNodes = [] as unknown as NodeList, removedNodes = [] as unknown as NodeList }) => { const { nodeType, tagName } = target as Element; return this.#isExcludedEls(addedNodes) || this.#isExcludedEls(removedNodes) || (nodeType === 1 && ((target as Element).closest(this.excludedSelector) || this.#isTagExcluded(tagName))); }); } /** * Callback-функция, вызываемая при изменениях содержимого документа * @type {MutationCallback} * @param mutationsList{MutationRecord} принимает список массив мутауий * @private */ #collectionObserveCallback(mutationsList: MutationRecord[]) { if (!this.#isExcludedMutationRecord(mutationsList)) { Observer.callbacks.forEach((callback) => callback(mutationsList)); } } /** * Проверяет, добавлен ли callback для указанного селектора класса * @param selector{String} селектор класса * @returns {Boolean} результат проверки */ static isCallbackExist(selector: string) { return Observer.callbacks.has(selector); } /** * Устанавливает callback для класса, вызываемый при изменениях в DOM * @param params{Object} параметры подписки * @param params.selector{String} селектор класса для идентификации * @param params.callback{Function} callback-функция (обычно инициатор класса) */ static set subscribe({ selector, callback }: { selector: string; callback: (mutationList: MutationRecord[]) => void; }) { if (typeof selector === "string" && typeof callback === "function" && !Observer.isCallbackExist(selector)) { Observer.callbacks.set(selector, callback); } } /** * Удаляет callback по селектору класса * @param selector{String} селектор класса для удаления */ static set unsubscribe(selector: string) { if (typeof selector === "string" && Observer.isCallbackExist(selector)) { Observer.callbacks.delete(selector); } } /** * Отключает наблюдатель и очищает все callback */ disconnect() { if (this.isConnected) { this.observer.disconnect(); this.isConnected = false; Observer.callbacks.clear(); if (this.isDebug) { console.debug("[Observer] disconnected"); } } } }