// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver } from '@singletons/events'; /** * Singleton with helper functions for dom. */ export class CoreDom { static fontSizeZoom: number | null = null; // Avoid creating singleton instances. private constructor() { // Nothing to do. } /** * Perform a dom closest function piercing the shadow DOM. * * @param node DOM Element. * @param selector Selector to search. * @returns Closest ancestor or null if not found. */ static closest(node: HTMLElement | Node | null, selector: string): T | null { if (!node) { return null; } if (node instanceof ShadowRoot) { return CoreDom.closest(node.host, selector); } if (node instanceof HTMLElement) { if (node.matches(selector)) { return node as unknown as T; } else { return CoreDom.closest(node.parentNode, selector); } } return CoreDom.closest(node.parentNode, selector); } /** * Retrieve the position of a element relative to another element. * * @param element Element to get the position. * @param parent Parent element to get relative position. * @returns X and Y position. */ static getRelativeElementPosition(element: HTMLElement, parent: HTMLElement): CoreCoordinates { // Get the top, left coordinates of two elements const elementRectangle = element.getBoundingClientRect(); const parentRectangle = parent.getBoundingClientRect(); // Calculate the top and left positions. return { x: elementRectangle.x - parentRectangle.x, y: elementRectangle.y - parentRectangle.y, }; } /** * Check whether an element has been added to the DOM. * * @param element Element. * @returns True if element has been added to the DOM, false otherwise. */ static isElementInDom(element: HTMLElement): boolean { return element.getRootNode({ composed: true }) === document; } /** * Check whether an element is intersecting the intersectionRatio in viewport. * * @param element Element to check. * @param intersectionRatio Intersection ratio (From 0 to 1). * @param container Container where element is located * @returns True if in viewport. */ static isElementInViewport(element: HTMLElement, intersectionRatio = 1, container: HTMLElement | null = null): boolean { const elementRectangle = element.getBoundingClientRect(); const containerRectangle = container?.getBoundingClientRect(); const elementArea = elementRectangle.width * elementRectangle.height; if (elementArea == 0) { return false; } const intersectionRectangle = { top: Math.max(containerRectangle?.top ?? 0, elementRectangle.top), left: Math.max(containerRectangle?.left ?? 0, elementRectangle.left), bottom: Math.min(containerRectangle?.bottom ?? window.innerHeight, elementRectangle.bottom), right: Math.min(containerRectangle?.right ?? window.innerWidth, elementRectangle.right), }; const intersectionArea = (intersectionRectangle.right - intersectionRectangle.left) * (intersectionRectangle.bottom - intersectionRectangle.top); return intersectionArea / elementArea >= intersectionRatio; } /** * Check whether an element is visible or not. * * @param element Element. * @param checkSize Wether to check size to check for visibility. * @returns True if element is visible inside the DOM. */ static isElementVisible(element: HTMLElement, checkSize = true): boolean { if (checkSize) { const dimensions = element.getBoundingClientRect(); if (dimensions.width === 0 || dimensions.height === 0) { return false; } } const style = getComputedStyle(element); if (style.opacity === '0' || style.display === 'none' || style.visibility === 'hidden') { return false; } return CoreDom.isElementInDom(element); } /** * Runs a function when an element has been slotted. * * @param element HTML Element inside an ion-content to wait for slot. * @param callback Function to execute on resize. */ static onElementSlot(element: HTMLElement, callback: (ev?: Event) => void): void { if (!element.slot) { // Element not declared to be slotted. return; } const slotName = element.slot; if (element.assignedSlot?.name === slotName) { // Slot already assigned. callback(); return; } const content = element.closest('ion-content'); if (!content || !content.shadowRoot) { // Cannot find content. return; } const slots = content.shadowRoot.querySelectorAll('slot'); const slot = Array.from(slots).find((slot) => slot.name === slotName); if (!slot) { // Slot not found. return; } const slotListener = () => { if (element.assignedSlot?.name !== slotName) { return; } callback(); // It would happen only once. slot.removeEventListener('slotchange', slotListener); }; slot.addEventListener('slotchange', slotListener); } /** * Window resize is widely checked and may have many performance issues, debouce usage is needed to avoid calling it too much. * This function helps setting up the debounce feature and remove listener easily. * * @param resizeFunction Function to execute on resize. * @param debounceDelay Debounce time in ms. * @returns Event observer to call off when finished. */ static onWindowResize(resizeFunction: (ev?: Event) => void, debounceDelay = 20): CoreEventObserver { const resizeListener = CoreUtils.debounce(async (ev?: Event) => { await CoreDomUtils.waitForResizeDone(); resizeFunction(ev); }, debounceDelay); window.addEventListener('resize', resizeListener); return { off: (): void => { window.removeEventListener('resize', resizeListener); }, }; } /** * Scroll to a certain element. * * @param element The element to scroll to. * @param selector Selector to find the element to scroll to inside the defined element. * @param scrollOptions Scroll Options. * @returns Wether the scroll suceeded. */ static async scrollToElement(element: HTMLElement, selector?: string, scrollOptions: CoreScrollOptions = {}): Promise { if (selector) { const foundElement = await CoreDom.waitToBeInsideElement(element, selector); if (!foundElement) { // Element not found. return false; } element = foundElement; } await CoreDom.waitToBeVisible(element, false); const content = element.closest('ion-content') ?? undefined; if (!content) { // Content to scroll, not found. return false; } try { const position = CoreDom.getRelativeElementPosition(element, content); const scrollElement = await content.getScrollElement(); scrollOptions.duration = scrollOptions.duration ?? 200; scrollOptions.addXAxis = scrollOptions.addXAxis ?? 0; scrollOptions.addYAxis = scrollOptions.addYAxis ?? 0; await content.scrollToPoint( position.x + scrollElement.scrollLeft + scrollOptions.addXAxis, position.y + scrollElement.scrollTop + scrollOptions.addYAxis, scrollOptions.duration, ); return true; } catch { return false; } } /** * Search for an input with error (core-input-error directive) and scrolls to it if found. * * @param container The element that contains the element that must be scrolled. * @returns True if the element is found, false otherwise. */ static async scrollToInputError(container: HTMLElement): Promise { return CoreDom.scrollToElement(container, '.core-input-error'); } /** * Has the scroll reached bottom? * * @param scrollElement Scroll Element. * @param marginError Error margin when calculating. * @returns Wether the scroll reached the bottom. */ static scrollIsBottom(scrollElement?: HTMLElement, marginError = 0): boolean { if (!scrollElement) { return true; } return scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - marginError; } /** * Move element to content so it can be slotted. * * @param element HTML Element. * @param slot Slot name. * @returns Promise resolved when done. */ static slotOnContent(element: HTMLElement, slot = 'fixed'): CoreCancellablePromise { element.setAttribute('slot', slot); if (element.parentElement?.nodeName === 'ION-CONTENT') { return CoreCancellablePromise.resolve(); } const domPromise = CoreDom.waitToBeInDOM(element); return new CoreCancellablePromise( async (resolve) => { await domPromise; // Move element to the nearest ion-content if it's not the parent if (element.parentElement?.nodeName !== 'ION-CONTENT') { element.closest('ion-content')?.appendChild(element); } resolve(); }, () => { domPromise.cancel(); }, ); } /** * Wait an element to be added to the root DOM. * * @param element Element to wait. * @returns Cancellable promise. */ static waitToBeInDOM(element: HTMLElement): CoreCancellablePromise { const root = element.getRootNode({ composed: true }); if (root === document) { // Already in DOM. return CoreCancellablePromise.resolve(); } let observer: MutationObserver; return new CoreCancellablePromise( (resolve) => { observer = new MutationObserver(() => { const root = element.getRootNode({ composed: true }); if (root !== document) { return; } observer?.disconnect(); resolve(); }); observer.observe(document.body, { subtree: true, childList: true }); }, () => { observer?.disconnect(); }, ); } /** * Wait an element to be in dom of another element using a selector * * @param container Element to wait. * @returns Cancellable promise. */ static async waitToBeInsideElement(container: HTMLElement, selector: string): Promise> { await CoreDom.waitToBeInDOM(container); let element = container.querySelector(selector); if (element) { // Already in DOM. return CoreCancellablePromise.resolve(element); } let observer: MutationObserver; return new CoreCancellablePromise( (resolve) => { observer = new MutationObserver(() => { element = container.querySelector(selector); if (!element) { return; } observer?.disconnect(); resolve(element); }); observer.observe(container, { subtree: true, childList: true }); }, () => { observer?.disconnect(); }, ); } /** * Watch whenever an elements visibility changes within the viewport. * * @param element Element to watch. * @param intersectionRatio Intersection ratio (From 0 to 1). * @param callback Callback when visibility changes. * @returns Function to stop watching. */ static watchElementInViewport( element: HTMLElement, intersectionRatio: number, callback: (visible: boolean) => void, ): () => void; /** * Watch whenever an elements visibility changes within the viewport. * * @param element Element to watch. * @param callback Callback when visibility changes. * @returns Function to stop watching. */ static watchElementInViewport(element: HTMLElement, callback: (visible: boolean) => void): () => void; static watchElementInViewport( element: HTMLElement, intersectionRatioOrCallback: number | ((visible: boolean) => void), callback?: (visible: boolean) => void, ): () => void { const visibleCallback = callback ?? intersectionRatioOrCallback as (visible: boolean) => void; const intersectionRatio = typeof intersectionRatioOrCallback === 'number' ? intersectionRatioOrCallback : 1; let visible = CoreDom.isElementInViewport(element, intersectionRatio); const setVisible = (newValue: boolean) => { if (visible === newValue) { return; } visible = newValue; visibleCallback(visible); }; if (!('IntersectionObserver' in window)) { const interval = setInterval(() => setVisible(CoreDom.isElementInViewport(element, intersectionRatio)), 50); return () => clearInterval(interval); } const observer = new IntersectionObserver(([{ isIntersecting, intersectionRatio }]) => { setVisible(isIntersecting && intersectionRatio >= intersectionRatio); }); observer.observe(element); return () => observer.disconnect(); } /** * Wait an element to be in dom and visible. * * @param element Element to wait. * @param intersectionRatio Intersection ratio (From 0 to 1). * @returns Cancellable promise. */ static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise { let unsubscribe: (() => void) | undefined; const visiblePromise = CoreDom.waitToBeVisible(element); return new CoreCancellablePromise( async (resolve) => { await visiblePromise; if (CoreDom.isElementInViewport(element, intersectionRatio)) { return resolve(); } unsubscribe = this.watchElementInViewport(element, intersectionRatio, inViewport => { if (!inViewport) { return; } resolve(); unsubscribe?.(); }); }, () => { visiblePromise.cancel(); unsubscribe?.(); }, ); } /** * Wait an element to be in dom and visible. * * @param element Element to wait. * @param checkSize Wether to check size to check for visibility. * @returns Cancellable promise. */ static waitToBeVisible(element: HTMLElement, checkSize = true): CoreCancellablePromise { const domPromise = CoreDom.waitToBeInDOM(element); let interval: number | undefined; // Mutations did not observe for visibility properties. return new CoreCancellablePromise( async (resolve) => { await domPromise; if (CoreDom.isElementVisible(element, checkSize)) { return resolve(); } interval = window.setInterval(() => { if (!CoreDom.isElementVisible(element, checkSize)) { return; } resolve(); window.clearInterval(interval); }, 50); }, () => { domPromise.cancel(); window.clearInterval(interval); }, ); } /** * Listen to click and Enter/Space keys in an element. * * @param element Element to listen to events. * @param callback Callback to call when clicked or the key is pressed. * @deprecated since 4.1.1: Use initializeClickableElementA11y instead. */ static onActivate( element: HTMLElement & {disabled?: boolean}, callback: (event: MouseEvent | KeyboardEvent) => void, ): void { this.initializeClickableElementA11y(element, callback); } /** * Initializes a clickable element a11y calling the click action when pressed enter or space * and adding tabindex and role if needed. * * @param element Element to listen to events. * @param callback Callback to call when clicked or the key is pressed. */ static initializeClickableElementA11y( element: HTMLElement & {disabled?: boolean}, callback: (event: MouseEvent | KeyboardEvent) => void, ): void { const enabled = () => !CoreUtils.isTrueOrOne(element.dataset.disabledA11yClicks ?? 'false'); element.addEventListener('click', (event) => enabled() && callback(event)); element.addEventListener('keydown', (event) => { if (!enabled()) { return; } if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); } }); element.addEventListener('keyup', (event) => { if (!enabled()) { return; } if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); callback(event); } }); if (element.tagName !== 'BUTTON' && element.tagName !== 'A') { // Set tabindex if not previously set. if (element.getAttribute('tabindex') === null) { element.setAttribute('tabindex', element.disabled ? '-1' : '0'); } // Set role if not previously set. if (!element.getAttribute('role')) { element.setAttribute('role', 'button'); } element.classList.add('clickable'); } } /** * Get CSS property value from computed styles. * * @param styles Computed styles. * @param property Property name. * @returns Property CSS value (may not be the same as the computed value). */ static getCSSPropertyValue(styles: CSSStyleDeclaration, property: string): string { const value = styles.getPropertyValue(property); if (property === 'font-size') { if (this.fontSizeZoom === null) { const baseFontSize = 20; const span = document.createElement('span'); span.style.opacity = '0'; span.style.fontSize = `${baseFontSize}px`; document.body.append(span); this.fontSizeZoom = baseFontSize / Number(getComputedStyle(span).fontSize.slice(0, -2)); span.remove(); } if (this.fontSizeZoom !== 1) { return `calc(${this.fontSizeZoom} * ${value})`; } } return value; } /** * Replace tags on HTMLElement. * * @param element HTML Element where to replace the tags. * @param originTags Origin tag to be replaced. * @param destinationTags Destination tag to replace. * @returns Element with tags replaced. */ static replaceTags( element: T, originTags: string | string[], destinationTags: string | string[], ): T { if (typeof originTags === 'string') { originTags = [originTags]; } if (typeof destinationTags === 'string') { destinationTags = [destinationTags]; } if (originTags.length !== destinationTags.length) { // Do nothing, incorrect input. return element; } originTags.forEach((originTag, index) => { const destinationTag = destinationTags[index]; const elems = Array.from(element.getElementsByTagName(originTag)); elems.forEach((elem) => { const newElem = document.createElement(destinationTag); newElem.innerHTML = elem.innerHTML; if (elem.hasAttributes()) { const attrs = Array.from(elem.attributes); attrs.forEach((attr) => { newElem.setAttribute(attr.name, attr.value); }); } elem.parentNode?.replaceChild(newElem, elem); }); }); return element; } } /** * Coordinates of an element. */ export type CoreCoordinates = { x: number; // X axis coordinates. y: number; // Y axis coordinates. }; /** * Scroll options. */ export type CoreScrollOptions = { duration?: number; addYAxis?: number; addXAxis?: number; }; /** * Source of a media element. */ export type CoreMediaSource = { src: string; type?: string; };