import Element from '../nodes/element/Element.js'; import * as PropertySymbol from '../PropertySymbol.js'; import SelectorItem from './SelectorItem.js'; import NodeList from '../nodes/node/NodeList.js'; import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; import SelectorCombinatorEnum from './SelectorCombinatorEnum.js'; import Document from '../nodes/document/Document.js'; import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; import SelectorParser from './SelectorParser.js'; import ISelectorMatch from './ISelectorMatch.js'; import IHTMLElementTagNameMap from '../config/IHTMLElementTagNameMap.js'; import ISVGElementTagNameMap from '../config/ISVGElementTagNameMap.js'; import ICachedQuerySelectorAllItem from '../nodes/node/ICachedQuerySelectorAllResult.js'; import ICachedQuerySelectorItem from '../nodes/node/ICachedQuerySelectorResult.js'; import ICachedMatchesItem from '../nodes/node/ICachedMatchesResult.js'; type DocumentPositionAndElement = { documentPosition: string; element: Element; }; /** * Invalid Selector RegExp. */ const INVALID_SELECTOR_REGEXP = /^[.#\[]?\d|[.#]$/; /** * Utility for query selection in an HTML element. * * @class QuerySelector */ export default class QuerySelector { /** * Finds elements based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML elements. */ public static querySelectorAll( node: Element | Document | DocumentFragment, selector: K ): NodeList; /** * Finds elements based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML elements. */ public static querySelectorAll( node: Element | Document | DocumentFragment, selector: K ): NodeList; /** * Finds elements based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML elements. */ public static querySelectorAll( node: Element | Document | DocumentFragment, selector: string ): NodeList; /** * Finds elements based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML elements. */ public static querySelectorAll( node: Element | Document | DocumentFragment, selector: string ): NodeList { const window = node[PropertySymbol.window]; if (selector === '') { throw new window.DOMException( `Failed to execute 'querySelectorAll' on '${node.constructor.name}': The provided selector is empty.` ); } if (typeof selector === 'function') { throw new window.DOMException( `Failed to execute 'querySelectorAll' on '${node.constructor.name}': '${selector}' is not a valid selector.` ); } if (typeof selector === 'symbol') { throw new window.TypeError(`Cannot convert a Symbol value to a string`); } selector = String(selector); if (INVALID_SELECTOR_REGEXP.test(selector)) { throw new window.DOMException( `Failed to execute 'querySelectorAll' on '${node.constructor.name}': '${selector}' is not a valid selector.` ); } const cache = node[PropertySymbol.cache].querySelectorAll; const cachedResult = cache.get(selector); if (cachedResult?.result) { const result = cachedResult.result.deref(); if (result) { return result; } } const groups = SelectorParser.getSelectorGroups(selector); const items: Element[] = []; const nodeList = new NodeList(PropertySymbol.illegalConstructor, items); const matchesMap: Map = new Map(); const matchedPositions: string[] = []; const cachedItem = { result: new WeakRef(nodeList) }; node[PropertySymbol.cache].querySelectorAll.set(selector, cachedItem); if (node[PropertySymbol.isConnected]) { // Document is affected for the ":target" selector (node[PropertySymbol.ownerDocument] || node)[PropertySymbol.affectsCache].push(cachedItem); } for (const items of groups) { const matches = node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? this.findAll(node, [node], items, cachedItem) : this.findAll(null, (node)[PropertySymbol.elementArray], items, cachedItem); for (const match of matches) { if (!matchesMap.has(match.documentPosition)) { matchesMap.set(match.documentPosition, match.element); matchedPositions.push(match.documentPosition); } } } const keys = matchedPositions.sort(); for (let i = 0, max = keys.length; i < max; i++) { items.push(matchesMap.get(keys[i])); } return nodeList; } /** * Finds an element based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML element. */ public static querySelector( node: Element | Document | DocumentFragment, selector: K ): IHTMLElementTagNameMap[K] | null; /** * Finds an element based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML element. */ public static querySelector( node: Element | Document | DocumentFragment, selector: K ): ISVGElementTagNameMap[K] | null; /** * Finds an element based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML element. */ public static querySelector( node: Element | Document | DocumentFragment, selector: string ): Element | null; /** * Finds an element based on a query selector. * * @param node Node to search in. * @param selector Selector. * @returns HTML element. */ public static querySelector( node: Element | Document | DocumentFragment, selector: string ): Element | null { const window = node[PropertySymbol.window]; if (selector === '') { throw new window.DOMException( `Failed to execute 'querySelector' on '${node.constructor.name}': The provided selector is empty.` ); } if (typeof selector === 'function' || typeof selector === 'symbol') { throw new window.DOMException( `Failed to execute 'querySelector' on '${node.constructor.name}': '${selector}' is not a valid selector.` ); } if (typeof selector === 'function') { throw new window.DOMException( `Failed to execute 'querySelector' on '${node.constructor.name}': '${selector}' is not a valid selector.` ); } if (typeof selector === 'symbol') { throw new window.TypeError(`Cannot convert a Symbol value to a string`); } selector = String(selector); if (INVALID_SELECTOR_REGEXP.test(selector)) { throw new window.DOMException( `Failed to execute 'querySelector' on '${node.constructor.name}': '${selector}' is not a valid selector.` ); } const cachedResult = node[PropertySymbol.cache].querySelector.get(selector); if (cachedResult?.result) { const result = cachedResult.result.deref(); if (result) { return result; } } const cachedItem: ICachedQuerySelectorItem = { result: >{ deref: () => null } }; node[PropertySymbol.cache].querySelector.set(selector, cachedItem); if (node[PropertySymbol.isConnected]) { // Document is affected for the ":target" selector (node[PropertySymbol.ownerDocument] || node)[PropertySymbol.affectsCache].push(cachedItem); } for (const items of SelectorParser.getSelectorGroups(selector)) { const match = node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? this.findFirst(node, [node], items, cachedItem) : this.findFirst(null, (node)[PropertySymbol.elementArray], items, cachedItem); if (match) { cachedItem.result = new WeakRef(match); return match; } } return null; } /** * Checks if an element matches a selector and returns priority weight. * * @param element Element to match. * @param selector Selector to match with. * @param [options] Options. * @param [options.ignoreErrors] Ignores errors. * @returns Result. */ public static matches( element: Element, selector: string, options?: { ignoreErrors?: boolean } ): ISelectorMatch | null { const ignoreErrors = options?.ignoreErrors; const window = element[PropertySymbol.window]; if (selector === '*') { return { priorityWeight: 1 }; } if (selector === '') { if (ignoreErrors) { return null; } throw new window.DOMException( `Failed to execute 'matches' on '${element.constructor.name}': The provided selector is empty.` ); } if (typeof selector === 'function') { if (ignoreErrors) { return null; } throw new window.DOMException( `Failed to execute 'matches' on '${element.constructor.name}': '${selector}' is not a valid selector.` ); } if (typeof selector === 'symbol') { if (ignoreErrors) { return null; } throw new window.TypeError(`Cannot convert a Symbol value to a string`); } selector = String(selector); if (INVALID_SELECTOR_REGEXP.test(selector)) { if (ignoreErrors) { return null; } throw new window.DOMException( `Failed to execute 'matches' on '${element.constructor.name}': '${selector}' is not a valid selector.` ); } const cachedResult = element[PropertySymbol.cache].matches.get(selector); if (cachedResult?.result) { return cachedResult.result.match; } const cachedItem: ICachedMatchesItem = { result: { match: null } }; element[PropertySymbol.cache].matches.set(selector, cachedItem); if (element[PropertySymbol.isConnected]) { // Document is affected for the ":target" selector (element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push( cachedItem ); } for (const items of SelectorParser.getSelectorGroups(selector, options)) { const result = this.matchSelector(element, items.reverse(), cachedItem); if (result) { cachedItem.result.match = result; return result; } } return null; } /** * Checks if a node matches a selector. * * @param element Target element. * @param currentElement * @param selectorItems Selector items. * @param cachedItem Cached item. * @param [previousSelectorItem] Previous selector item. * @param [priorityWeight] Priority weight. * @returns Result. */ private static matchSelector( element: Element, selectorItems: SelectorItem[], cachedItem: ICachedMatchesItem, previousSelectorItem: SelectorItem | null = null, priorityWeight = 0 ): ISelectorMatch | null { const selectorItem = selectorItems[0]; const result = selectorItem.match(element); if (result) { if (selectorItems.length === 1) { return { priorityWeight: priorityWeight + result.priorityWeight }; } switch (selectorItem.combinator) { case SelectorCombinatorEnum.adjacentSibling: const previousElementSibling = element.previousElementSibling; if (previousElementSibling) { previousElementSibling[PropertySymbol.affectsCache].push(cachedItem); const match = this.matchSelector( previousElementSibling, selectorItems.slice(1), cachedItem, selectorItem, priorityWeight + result.priorityWeight ); if (match) { return match; } } break; case SelectorCombinatorEnum.child: case SelectorCombinatorEnum.descendant: const parentElement = element.parentElement; if (parentElement) { parentElement[PropertySymbol.affectsCache].push(cachedItem); const match = this.matchSelector( parentElement, selectorItems.slice(1), cachedItem, selectorItem, priorityWeight + result.priorityWeight ); if (match) { return match; } } break; } } if (previousSelectorItem?.combinator === SelectorCombinatorEnum.descendant) { const parentElement = element.parentElement; if (parentElement) { return this.matchSelector( parentElement, selectorItems, cachedItem, previousSelectorItem, priorityWeight ); } } return null; } /** * Finds elements based on a query selector for a part of a list of selectors separated with comma. * * @param rootElement Root element. * @param children Child elements. * @param selectorItems Selector items. * @param cachedItem Cached item. * @param [documentPosition] Document position of the element. * @returns Document position and element map. */ private static findAll( rootElement: Element, children: Element[], selectorItems: SelectorItem[], cachedItem: ICachedQuerySelectorAllItem, documentPosition?: string ): DocumentPositionAndElement[] { const selectorItem = selectorItems[0]; const nextSelectorItem = selectorItems[1]; let matched: DocumentPositionAndElement[] = []; for (let i = 0, max = children.length; i < max; i++) { const child = children[i]; const childrenOfChild = (child)[PropertySymbol.elementArray]; const position = (documentPosition ? documentPosition + '>' : '') + String.fromCharCode(i); child[PropertySymbol.affectsCache].push(cachedItem); if (selectorItem.match(child)) { if (!nextSelectorItem) { if (rootElement !== child) { matched.push({ documentPosition: position, element: child }); } } else { switch (nextSelectorItem.combinator) { case SelectorCombinatorEnum.adjacentSibling: if (child.nextElementSibling) { matched = matched.concat( this.findAll( rootElement, [child.nextElementSibling], selectorItems.slice(1), cachedItem, position ) ); } break; case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: matched = matched.concat( this.findAll( rootElement, childrenOfChild, selectorItems.slice(1), cachedItem, position ) ); break; } } } if (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) { matched = matched.concat( this.findAll(rootElement, childrenOfChild, selectorItems, cachedItem, position) ); } } return matched; } /** * Finds an element based on a query selector for a part of a list of selectors separated with comma. * * @param rootElement Root element. * @param children Child elements. * @param selectorItems Selector items. * @param cachedItem Cached item. * @returns HTML element. */ private static findFirst( rootElement: Element, children: Element[], selectorItems: SelectorItem[], cachedItem: ICachedQuerySelectorItem ): Element { const selectorItem = selectorItems[0]; const nextSelectorItem = selectorItems[1]; for (const child of children) { const childrenOfChild = (child)[PropertySymbol.elementArray]; child[PropertySymbol.affectsCache].push(cachedItem); if (selectorItem.match(child)) { if (!nextSelectorItem) { if (rootElement !== child) { return child; } } else { switch (nextSelectorItem.combinator) { case SelectorCombinatorEnum.adjacentSibling: if (child.nextElementSibling) { const match = this.findFirst( rootElement, [child.nextElementSibling], selectorItems.slice(1), cachedItem ); if (match) { return match; } } break; case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: const match = this.findFirst( rootElement, childrenOfChild, selectorItems.slice(1), cachedItem ); if (match) { return match; } break; } } } if (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) { const match = this.findFirst(rootElement, childrenOfChild, selectorItems, cachedItem); if (match) { return match; } } } return null; } }