import { isNode } from "@web3r/flowerkit/dom"; import { getDocument } from "ssr-window"; import { Dispatcher } from "../dispatcher"; import { Observer } from "../observer"; /** * Базовый контракт экземпляра элемента коллекции. */ export interface ICollectionItem { instance: TCollectionElement; } /** * DOM-элемент, учитываемый в коллекции плагинов. * Добавляет к узлу пропс `isInCollection` */ export type TCollectionElement = Element & { isInCollection?: boolean; }; /** * Базовый тип параметров конструктора элемента коллекции. */ export type TCollectionItemDefaultCfg = Record; /** * Тип конструктора плагина, совместимого с `Collection`. * @template T Тип экземпляра элемента коллекции. */ export type TCollectionPluginCtor = new ( el: TCollectionElement, params?: TCollectionItemDefaultCfg, ) => T; /** * Factory. Хранилище для экземпляров вызовов других плагинов, а также набор классов, доступных для наследования и служащих для ускорения проектирования простых пользовательских плагинов. * @module Collection * @param selector{String} селектор для идентификации класса * @param plugin{Function} плагин, добавляемый в коллекцию * @param isAutoInit{Boolean=} автоматический вызов методов `init` и `bindEvents` (подходит для большинства обычных коллекций) * @example * import { Collection } from "@delement/ui/plugins" * * class MyPlugin { * * constructor(el) { * this.instance = el; * } * * } * * export default class CollectionMyPlugin extends Collection { * * static selector = "[data-js-MyPlugin]"; * * constructor() { * super(CollectionMyPlugin.selector, MyPlugin) * } * * beforeAddCallback() { * // код выполнится, когда элемент добавится в коллекцию * } * * } */ class Collection { /** * Коллекция экземпляров * @type {Array} * @private */ #collectionStorage: T[] = []; collectionObserverSelector = ""; collectionObserverClass: TCollectionPluginCtor | null = null; /** * События * @type {{mount: string, unmount: string}} */ static bubbles = { mount: "collection::mount", unmount: "collection::unmount", }; constructor(selector: string, plugin: TCollectionPluginCtor, isAutoInit = true) { if (typeof selector === "string" && typeof plugin === "function") { this.collectionObserverSelector = selector; this.collectionObserverClass = plugin; Observer.subscribe = { selector, callback: this.#collectionObserveCallback.bind(this), }; if (isAutoInit) { this.init(); this.bindEvents(); } } else { Collection.#showError("Selector or class are not defined while extending", this); } } /** * Инициализирует коллекцию, добавляя все элементы по селектору в указанном контексте * @param context{Document|HTMLElement=} контекст для поиска элементов, по умолчанию document */ init(context: Document | HTMLElement = getDocument()) { if ("querySelectorAll" in context) { context .querySelectorAll(this.collectionObserverSelector) .forEach((el: Element) => this.addToCollection(el as TCollectionElement)); } else { console.debug(`[Collection]: Context for selector "${this.collectionObserverSelector}" is not a node`); } } /** * Подписывается на обновления страницы для автоматического вызова init (по умолчанию через Dispatcher) */ bindEvents() { Dispatcher.initiator = { selector: this.collectionObserverSelector, initiator: (context: Document | HTMLElement) => this.init(context), getCollection: () => this.collection || [], }; } /** * Информирование об ошибках * @param msg{String} сообщение * @param obj{Object} объект с информацией * @private */ static #showError(msg: string, obj: unknown) { console.error(`[Collection]: ${msg}`, obj); } /** * Добавление экземпляра класса в коллекцию. По-умолчанию проверяет, существует ли экземпляр с таким instance. Если существует, то добавления не происходит * @param newClass{Function|Class} */ #appendToCollection(newClass: T): void { const { instance } = newClass; if (instance && !instance.isInCollection) { const itemInCollection = this.getByDOMElement(instance); if (!itemInCollection) { this.beforeAddCallback(instance, newClass); this.#collectionStorage = [ ...this.#collectionStorage, newClass ]; this.afterAddCallback(instance, newClass); } } else { Collection.#showError("Missed instance field for item: ", newClass); } } /** * Получает актуальный массив экземпляров коллекции * @template T - тип элементов коллекции * @returns {T[]} массив экземпляров */ get collection(): T[] { return this.#collectionStorage; } /** * Ищет элемент внутри коллекции по DOM-элементу путём сверки поля instance * @param el{Element} DOM-элемент для поиска * @returns {T|null} найденный экземпляр или null */ getByDOMElement(el: Element) { return this.collection.find((item: T) => item.instance === el) || null; } /** * Ищет элемент внутри коллекции по индексу * @param index{Number=} индекс элемента, по умолчанию 0 * @returns {T|null} найденный экземпляр или null */ getByIndex(index = 0) { return this.collection[index] || null; } /** * Callback, вызываемый перед удалением элемента из коллекции * @param el{Element} удаляемый из коллекции элемент * @param instance{T} экземпляр вызова класса */ beforeRemoveCallback(el: TCollectionElement, instance: T) { } /** * Callback, вызываемый после удаления элемента из коллекции * @param el{Element} удалённый из коллекции элемент * @param instance{T} экземпляр вызова класса */ afterRemoveCallback(el: TCollectionElement, instance: T) { } /** * Callback, вызываемый перед добавлением элемента в коллекцию * @param el{Element} добавляемый в коллекцию элемент * @param instance{T} экземпляр вызова класса */ beforeAddCallback(el: TCollectionElement, instance: T) { } /** * Callback, вызываемый после добавления элемента в коллекцию * @param el{Element} добавленный в коллекцию элемент * @param instance{T} экземпляр вызова класса */ afterAddCallback(el: TCollectionElement, instance: T) { } /** * Удаляет элемент из коллекции по экземпляру класса * @param collectionClass{T} экземпляр класса для удаления */ removeFromCollection(collectionClass: T) { const { instance } = collectionClass; if (instance) { this.beforeRemoveCallback(instance, collectionClass); const collectionItemIndex = this.collection.indexOf(collectionClass); if (collectionItemIndex < 0) { return; } this.#collectionStorage.splice(collectionItemIndex, 1); this.setElementState(instance, false); this.afterRemoveCallback(instance, collectionClass); } } /** * Callback, вызываемый при изменении содержимого страниц, поступающих через Watcher */ #collectionObserveCallback(mutationsList: MutationRecord[] = []) { if (!mutationsList.length) { this.#collectionObserveRemoving(); this.#collectionObserveAdding(); return; } const removedElements = new Set(); const addedElements = new Set(); mutationsList.forEach(({ addedNodes, removedNodes }) => { this.#getCollectionElementsFromNodes(removedNodes) .forEach((el: TCollectionElement) => removedElements.add(el)); this.#getCollectionElementsFromNodes(addedNodes) .forEach((el: TCollectionElement) => addedElements.add(el)); }); if (!removedElements.size && !addedElements.size) { this.#collectionObserveRemoving(); this.#collectionObserveAdding(); return; } this.#collectionObserveRemoving([ ...removedElements ]); this.#collectionObserveAdding([ ...addedElements ]); } #getCollectionElementsFromNodes(nodes: NodeList = [] as unknown as NodeList): TCollectionElement[] { const elements = new Set(); [ ...nodes ].forEach((node) => { if (!(node instanceof Element)) { return; } if (node.matches(this.collectionObserverSelector)) { elements.add(node as TCollectionElement); } node.querySelectorAll(this.collectionObserverSelector) .forEach((el: Element) => elements.add(el as TCollectionElement)); }); return [ ...elements ]; } /** * Проверка отсутствия DOM-элементов коллекции на странице после изменений и удаление пустых классов */ #collectionObserveRemoving(removedElements: TCollectionElement[] = []) { if (removedElements.length) { removedElements.forEach((el: TCollectionElement) => { if (el.isConnected) { return; } const found = this.getByDOMElement(el); if (found) { this.removeFromCollection(found); } }); return; } const target = Observer.getTarget(); if (!(target instanceof Node)) { return; } this.collection.forEach((collectionItem: T) => { if (!target.contains(collectionItem.instance)) { this.removeFromCollection(collectionItem); } }); } /** * Устанавливает маркер состояния DOM-элемента (инициализирован или удалён из коллекции) * @param el{Element} DOM-элемент * @param isInCollection{Boolean=} маркер состояния (true = в коллекции) */ setElementState(el: TCollectionElement, isInCollection = true) { el.isInCollection = isInCollection; } /** * Проверяет валидность элемента перед добавлением в коллекцию. * Полезно, если необходимо исключить вызов класса для определенных элементов. * @returns {Boolean} */ isValid(el: TCollectionElement): boolean { return true; } /** * Добавляет элемент или экземпляр класса в коллекцию * @template T * @returns {T|null} возвращает экземпляр класса или null, если элемент уже в коллекции */ addToCollection(item: TCollectionElement | T, params: Record = {}) { const inputEl = isNode(item as unknown as Node) ? item as TCollectionElement : (item as T).instance; if (inputEl) { if (!this.isValid(inputEl)) { return null; } const el = inputEl as TCollectionElement; if (!this.getByDOMElement(el) && !el.isInCollection) { if (!this.collectionObserverClass) { Collection.#showError("Observer class is not defined", this); return null; } const collectionClass = (item instanceof this.collectionObserverClass) ? item as T : new this.collectionObserverClass(el, params); collectionClass.instance = collectionClass.instance || el; this.#appendToCollection(collectionClass); this.setElementState(el); return collectionClass; } else { return null; } } else { Collection.#showError("Can't find element of collection item:", item); return null; } } /** * Проверка присутствия DOM-элементов коллекции на странице после изменений и добавление новых классов */ #collectionObserveAdding(addedElements: TCollectionElement[] = []) { if (addedElements.length) { addedElements.forEach((el: TCollectionElement) => this.addToCollection(el)); return; } const target = Observer.getTarget(); if ( typeof target === "object" && target !== null && "querySelectorAll" in target && typeof target.querySelectorAll === "function" ) { const queryRoot = target as Document | Element; queryRoot.querySelectorAll(this.collectionObserverSelector) .forEach((el: Element) => this.addToCollection(el as TCollectionElement)); } else { console.debug(`[Collection]: Context for selector "${this.collectionObserverSelector}" is not a node`); } } } export { Collection, };