// (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 { Injectable, SimpleChange, ElementRef, KeyValueChanges } from '@angular/core'; import { IonContent } from '@ionic/angular'; import { ModalOptions, PopoverOptions, AlertOptions, AlertButton, TextFieldTypes, getMode, ToastOptions } from '@ionic/core'; import { Md5 } from 'ts-md5'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreFile } from '@services/file'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreTextUtils, CoreTextErrorObject } from '@services/utils/text'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils } from '@services/utils/utils'; import { CoreConstants } from '@/core/constants'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreAnyError, CoreError } from '@classes/errors/error'; import { CoreSilentError } from '@classes/errors/silenterror'; import { makeSingleton, Translate, AlertController, ToastController, PopoverController, ModalController, Router, } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreFileSizeSum } from '@services/plugin-file-delegate'; import { CoreNetworkError } from '@classes/errors/network-error'; import { CoreBSTooltipComponent } from '@components/bs-tooltip/bs-tooltip'; import { CoreViewerImageComponent } from '@features/viewer/components/image/image'; import { CoreFormFields, CoreForms } from '../../singletons/form'; import { CoreModalLateralTransitionEnter, CoreModalLateralTransitionLeave } from '@classes/modal-lateral-transition'; import { CoreZoomLevel } from '@features/settings/services/settings-helper'; import { AddonFilterMultilangHandler } from '@addons/filter/multilang/services/handlers/multilang'; import { CoreSites } from '@services/sites'; import { NavigationStart } from '@angular/router'; import { filter } from 'rxjs/operators'; import { Subscription } from 'rxjs'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreNetwork } from '@services/network'; import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreUserSupport } from '@features/user/services/support'; import { CoreErrorInfoComponent } from '@components/error-info/error-info'; import { CorePlatform } from '@services/platform'; /* * "Utils" service with helper functions for UI, DOM elements and HTML code. */ @Injectable({ providedIn: 'root' }) export class CoreDomUtilsProvider { protected readonly INSTANCE_ID_ATTR_NAME = 'core-instance-id'; // List of input types that support keyboard. protected readonly INPUT_SUPPORT_KEYBOARD: string[] = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', 'tel', 'text', 'time', 'url', 'week']; protected template: HTMLTemplateElement = document.createElement('template'); // A template element to convert HTML to element. protected matchesFunctionName?: string; // Name of the "matches" function to use when simulating a closest call. protected debugDisplay = false; // Whether to display debug messages. Store it in a variable to make it synchronous. protected displayedAlerts: Record = {}; // To prevent duplicated alerts. protected displayedModals: Record = {}; // To prevent duplicated modals. protected activeLoadingModals: CoreIonLoadingElement[] = []; protected logger: CoreLogger; constructor() { this.logger = CoreLogger.getInstance('CoreDomUtilsProvider'); this.init(); } /** * Init some properties. */ protected async init(): Promise { // Check if debug messages should be displayed. const debugDisplay = await CoreConfig.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, 0); this.debugDisplay = debugDisplay != 0; } /** * Equivalent to element.closest(). If the browser doesn't support element.closest, it will * traverse the parents to achieve the same functionality. * Returns the closest ancestor of the current element (or the current element itself) which matches the selector. * * @param element DOM Element. * @param selector Selector to search. * @returns Closest ancestor. * @deprecated since app 4.0 Not needed anymore since it's supported on both Android and iOS. Use closest instead. */ closest(element: Element | undefined | null, selector: string): Element | null { return element?.closest(selector) ?? null; } /** * If the download size is higher than a certain threshold shows a confirm dialog. * * @param size Object containing size to download and a boolean to indicate if its totally or partialy calculated. * @param message Code of the message to show. Default: 'core.course.confirmdownload'. * @param unknownMessage ID of the message to show if size is unknown. * @param wifiThreshold Threshold to show confirm in WiFi connection. Default: CoreWifiDownloadThreshold. * @param limitedThreshold Threshold to show confirm in limited connection. Default: CoreDownloadThreshold. * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. * @returns Promise resolved when the user confirms or if no confirm needed. */ async confirmDownloadSize( size: CoreFileSizeSum, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number, alwaysConfirm?: boolean, ): Promise { const readableSize = CoreTextUtils.bytesToSize(size.size, 2); const getAvailableBytes = async (): Promise => { const availableBytes = await CoreFile.calculateFreeSpace(); if (CorePlatform.isAndroid()) { return availableBytes; } else { // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. // We'll only use it when space is <500MB, or we're downloading more than twice the reported space. if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { return availableBytes; } else { return null; } } }; const getAvailableSpace = (availableBytes: number | null): string => { if (availableBytes === null) { return ''; } else { const availableSize = CoreTextUtils.bytesToSize(availableBytes, 2); if (CorePlatform.isAndroid() && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { throw new CoreError( Translate.instant( 'core.course.insufficientavailablespace', { size: readableSize }, ), ); } return Translate.instant('core.course.availablespace', { available: availableSize }); } }; const availableBytes = await getAvailableBytes(); const availableSpace = getAvailableSpace(availableBytes); wifiThreshold = wifiThreshold === undefined ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; limitedThreshold = limitedThreshold === undefined ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; let wifiPrefix = ''; if (CoreNetwork.isNetworkAccessLimited()) { wifiPrefix = Translate.instant('core.course.confirmlimiteddownload'); } if (size.size < 0 || (size.size == 0 && !size.total)) { // Seems size was unable to be calculated. Show a warning. unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; return this.showConfirm( wifiPrefix + Translate.instant( unknownMessage, { availableSpace: availableSpace }, ), ); } else if (!size.total) { // Filesize is only partial. return this.showConfirm( wifiPrefix + Translate.instant( 'core.course.confirmpartialdownloadsize', { size: readableSize, availableSpace: availableSpace }, ), ); } else if (alwaysConfirm || size.size >= wifiThreshold || (CoreNetwork.isNetworkAccessLimited() && size.size >= limitedThreshold)) { message = message || (size.size === 0 ? 'core.course.confirmdownloadzerosize' : 'core.course.confirmdownload'); return this.showConfirm( wifiPrefix + Translate.instant( message, { size: readableSize, availableSpace: availableSpace }, ), ); } } /** * Convert some HTML as text into an HTMLElement. This HTML is put inside a div or a body. * * @param html Text to convert. * @returns Element. */ convertToElement(html: string): HTMLElement { // Add a div to hold the content, that's the element that will be returned. this.template.innerHTML = '
' + html + '
'; return this.template.content.children[0]; } /** * Create a "cancelled" error. These errors won't display an error message in showErrorModal functions. * * @returns The error object. * @deprecated since 3.9.5. Just create the error directly. */ createCanceledError(): CoreCanceledError { return new CoreCanceledError(''); } /** * Given a list of changes for a component input detected by a KeyValueDiffers, create an object similar to the one * passed to the ngOnChanges functions. * * @param changes Changes detected by KeyValueDiffer. * @returns Changes in a format like ngOnChanges. */ createChangesFromKeyValueDiff(changes: KeyValueChanges): { [name: string]: SimpleChange } { const newChanges: { [name: string]: SimpleChange } = {}; // Added items are considered first change. changes.forEachAddedItem((item) => { newChanges[item.key] = new SimpleChange(item.previousValue, item.currentValue, true); }); // Changed or removed items aren't first change. changes.forEachChangedItem((item) => { newChanges[item.key] = new SimpleChange(item.previousValue, item.currentValue, false); }); changes.forEachRemovedItem((item) => { newChanges[item.key] = new SimpleChange(item.previousValue, item.currentValue, true); }); return newChanges; } /** * Search all the URLs in a CSS file content. * * @param code CSS code. * @returns List of URLs. */ extractUrlsFromCSS(code: string): string[] { // First of all, search all the url(...) occurrences that don't include "data:". const urls: string[] = []; const matches = code.match(/url\(\s*["']?(?!data:)([^)]+)\)/igm); if (!matches) { return urls; } // Extract the URL from each match. matches.forEach((match) => { const submatches = match.match(/url\(\s*['"]?([^'"]*)['"]?\s*\)/im); if (submatches?.[1]) { urls.push(submatches[1]); } }); return urls; } /** * Fix syntax errors in HTML. * * @param html HTML text. * @returns Fixed HTML text. */ fixHtml(html: string): string { this.template.innerHTML = html; // eslint-disable-next-line no-control-regex const attrNameRegExp = /[^\x00-\x20\x7F-\x9F"'>/=]+/; const fixElement = (element: Element): void => { // Remove attributes with an invalid name. Array.from(element.attributes).forEach((attr) => { if (!attrNameRegExp.test(attr.name)) { element.removeAttributeNode(attr); } }); Array.from(element.children).forEach(fixElement); }; Array.from(this.template.content.children).forEach(fixElement); return this.template.innerHTML; } /** * Focus an element and open keyboard. * * @param element HTML element to focus. */ async focusElement( element: HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSearchbarElement | HTMLElement, ): Promise { let retries = 10; let focusElement = element; if ('getInputElement' in focusElement) { // If it's an Ionic element get the right input to use. focusElement.componentOnReady && await focusElement.componentOnReady(); focusElement = await focusElement.getInputElement(); } if (!focusElement || !focusElement.focus) { throw new CoreError('Element to focus cannot be focused'); } while (retries > 0 && focusElement !== document.activeElement) { focusElement.focus(); if (focusElement === document.activeElement) { await CoreUtils.nextTick(); if (CorePlatform.isAndroid() && this.supportsInputKeyboard(focusElement)) { // On some Android versions the keyboard doesn't open automatically. CoreApp.openKeyboard(); } break; } // @TODO Probably a Mutation Observer would get this working. await CoreUtils.wait(50); retries--; } } /** * Formats a size to be used as width/height of an element. * If the size is already valid (like '500px' or '50%') it won't be modified. * Returned size will have a format like '500px'. * * @param size Size to format. * @returns Formatted size. If size is not valid, returns an empty string. */ formatPixelsSize(size: string | number): string { if (typeof size == 'string' && (size.indexOf('px') > -1 || size.indexOf('%') > -1 || size == 'auto' || size == 'initial')) { // It seems to be a valid size. return size; } if (typeof size == 'string') { // It's important to use parseInt instead of Number because Number('') is 0 instead of NaN. size = parseInt(size, 10); } if (!isNaN(size)) { return size + 'px'; } return ''; } /** * Returns the contents of a certain selection in a DOM element. * * @param element DOM element to search in. * @param selector Selector to search. * @returns Selection contents. Undefined if not found. */ getContentsOfElement(element: HTMLElement, selector: string): string | undefined { const selected = element.querySelector(selector); if (selected) { return selected.innerHTML; } } /** * Get the data from a form. It will only collect elements that have a name. * * @param form The form to get the data from. * @returns Object with the data. The keys are the names of the inputs. * @deprecated since 3.9.5. Function has been moved to CoreForms. */ getDataFromForm(form: HTMLFormElement): CoreFormFields { return CoreForms.getDataFromForm(form); } /** * Returns the attribute value of a string element. Only the first element will be selected. * * @param html HTML element in string. * @param attribute Attribute to get. * @returns Attribute value. */ getHTMLElementAttribute(html: string, attribute: string): string | null { return this.convertToElement(html).children[0].getAttribute(attribute); } /** * Returns height of an element. * * @param element DOM element to measure. * @param usePadding Whether to use padding to calculate the measure. * @param useMargin Whether to use margin to calculate the measure. * @param useBorder Whether to use borders to calculate the measure. * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. * @returns Height in pixels. * @deprecated since app 4.0 Use getBoundingClientRect.height instead. */ getElementHeight( element: HTMLElement, usePadding?: boolean, useMargin?: boolean, useBorder?: boolean, innerMeasure?: boolean, ): number { return this.getElementMeasure(element, false, usePadding, useMargin, useBorder, innerMeasure); } /** * Returns height or width of an element. * * @param element DOM element to measure. * @param getWidth Whether to get width or height. * @param usePadding Whether to use padding to calculate the measure. * @param useMargin Whether to use margin to calculate the measure. * @param useBorder Whether to use borders to calculate the measure. * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. * @returns Measure in pixels. * @deprecated since app 4.0 Use getBoundingClientRect.height or width instead. */ getElementMeasure( element: HTMLElement, getWidth?: boolean, usePadding?: boolean, useMargin?: boolean, useBorder?: boolean, innerMeasure?: boolean, ): number { const offsetMeasure = getWidth ? 'offsetWidth' : 'offsetHeight'; const measureName = getWidth ? 'width' : 'height'; const clientMeasure = getWidth ? 'clientWidth' : 'clientHeight'; const priorSide = getWidth ? 'Left' : 'Top'; const afterSide = getWidth ? 'Right' : 'Bottom'; let measure = element[offsetMeasure] || element[measureName] || element[clientMeasure] || 0; // Measure not correctly taken. if (measure <= 0) { const style = getComputedStyle(element); if (style?.display == '') { element.style.display = 'inline-block'; measure = element[offsetMeasure] || element[measureName] || element[clientMeasure] || 0; element.style.display = ''; } } if (usePadding || useMargin || useBorder) { const computedStyle = getComputedStyle(element); let surround = 0; if (usePadding) { surround += this.getComputedStyleMeasure(computedStyle, 'padding' + priorSide) + this.getComputedStyleMeasure(computedStyle, 'padding' + afterSide); } if (useMargin) { surround += this.getComputedStyleMeasure(computedStyle, 'margin' + priorSide) + this.getComputedStyleMeasure(computedStyle, 'margin' + afterSide); } if (useBorder) { surround += this.getComputedStyleMeasure(computedStyle, 'border' + priorSide + 'Width') + this.getComputedStyleMeasure(computedStyle, 'border' + afterSide + 'Width'); } if (innerMeasure) { measure = measure > surround ? measure - surround : 0; } else { measure += surround; } } return measure; } /** * Returns the computed style measure or 0 if not found or NaN. * * @param style Style from getComputedStyle. * @param measure Measure to get. * @returns Result of the measure. */ getComputedStyleMeasure(style: CSSStyleDeclaration, measure: string): number { return parseInt(style[measure], 10) || 0; } /** * Returns width of an element. * * @param element DOM element to measure. * @param usePadding Whether to use padding to calculate the measure. * @param useMargin Whether to use margin to calculate the measure. * @param useBorder Whether to use borders to calculate the measure. * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. * @returns Width in pixels. * @deprecated since app 4.0 Use getBoundingClientRect.width instead. */ getElementWidth( element: HTMLElement, usePadding?: boolean, useMargin?: boolean, useBorder?: boolean, innerMeasure?: boolean, ): number { return this.getElementMeasure(element, true, usePadding, useMargin, useBorder, innerMeasure); } /** * Retrieve the position of a element relative to another element. * * @param element Element to search in. * @param selector Selector to find the element to gets the position. * @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll. * @returns positionLeft, positionTop of the element relative to. * @deprecated since app 4.0. Use CoreDom.getRelativeElementPosition instead. */ getElementXY(element: HTMLElement, selector?: string, positionParentClass = 'inner-scroll'): [number, number] | null { if (selector) { const foundElement = element.querySelector(selector); if (!foundElement) { // Element not found. return null; } element = foundElement; } const parent = element.closest(`.${positionParentClass}`); if (!parent) { return null; } const position = CoreDom.getRelativeElementPosition(element, parent); // Calculate the top and left positions. return [ Math.ceil(position.x), Math.ceil(position.y), ]; } /** * Given a message, it deduce if it's a network error. * * @param message Message text. * @param error Error object. * @returns True if the message error is a network error, false otherwise. */ protected isNetworkError(message: string, error?: CoreError | CoreTextErrorObject | string): boolean { return message == Translate.instant('core.networkerrormsg') || message == Translate.instant('core.fileuploader.errormustbeonlinetoupload') || error instanceof CoreNetworkError; } /** * Given a message, check if it's a site unavailable error. * * @param message Message text. * @returns Whether the message is a site unavailable error. */ protected isSiteUnavailableError(message: string): boolean { let siteUnavailableMessage = Translate.instant('core.siteunavailablehelp', { site: 'SITEURLPLACEHOLDER' }); siteUnavailableMessage = CoreTextUtils.escapeForRegex(siteUnavailableMessage); siteUnavailableMessage = siteUnavailableMessage.replace('SITEURLPLACEHOLDER', '.*'); return new RegExp(siteUnavailableMessage).test(message); } /** * Get the error message from an error, including debug data if needed. * * @param error Message to show. * @param needsTranslate Whether the error needs to be translated. * @returns Error message, null if no error should be displayed. */ getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string | null { if (typeof error != 'string' && !error) { return null; } let extraInfo = ''; let errorMessage: string | undefined; if (typeof error == 'object') { if (this.debugDisplay) { // Get the debug info. Escape the HTML so it is displayed as it is in the view. if ('debuginfo' in error && error.debuginfo) { extraInfo = '

' + CoreTextUtils.escapeHTML(error.debuginfo, false); } if ('backtrace' in error && error.backtrace) { extraInfo += '

' + CoreTextUtils.replaceNewLines( CoreTextUtils.escapeHTML(error.backtrace, false), '
', ); } // eslint-disable-next-line no-console console.error(error); } if (this.isSilentError(error)) { // It's a silent error, don't display an error. return null; } // We received an object instead of a string. Search for common properties. errorMessage = CoreTextUtils.getErrorMessageFromError(error); if (!errorMessage) { // No common properties found, just stringify it. errorMessage = JSON.stringify(error); extraInfo = ''; // No need to add extra info because it's already in the error. } // Try to remove tokens from the contents. const matches = errorMessage.match(/token"?[=|:]"?(\w*)/); if (matches?.[1]) { errorMessage = errorMessage.replace(new RegExp(matches[1], 'g'), 'secret'); } } else { errorMessage = error; } if (errorMessage == CoreConstants.DONT_SHOW_ERROR) { // The error shouldn't be shown, stop. return null; } let message = CoreTextUtils.decodeHTML(needsTranslate ? Translate.instant(errorMessage) : errorMessage); if (extraInfo) { message += extraInfo; } return message; } /** * Retrieve component/directive instance. * Please use this function only if you cannot retrieve the instance using parent/child methods: ViewChild (or similar) * or Angular's injection. * * @param element The root element of the component/directive. * @returns The instance, undefined if not found. * @deprecated since 4.0.0. Use CoreDirectivesRegistry instead. */ getInstanceByElement(element: Element): T | undefined { return CoreDirectivesRegistry.resolve(element) ?? undefined; } /** * Check whether an error is an error caused because the user canceled a showConfirm. * * @param error Error to check. * @returns Whether it's a canceled error. */ isCanceledError(error: CoreAnyError): boolean { return error instanceof CoreCanceledError; } /** * Check whether an error is an error caused because the user canceled a showConfirm. * * @param error Error to check. * @returns Whether it's a canceled error. */ isSilentError(error: CoreAnyError): boolean { return error instanceof CoreSilentError; } /** * Wait an element to exists using the findFunction. * * @param findFunction The function used to find the element. * @param retries Number of retries before giving up. * @param retryAfter Milliseconds to wait before retrying if the element wasn't found. * @returns Resolved if found, rejected if too many tries. * @deprecated since app 4.0 Use CoreDom.waitToBeInsideElement instead. */ async waitElementToExist( findFunction: () => HTMLElement | null, retries: number = 100, retryAfter: number = 100, ): Promise { const element = findFunction(); if (!element && retries === 0) { throw Error('Element not found'); } if (!element) { await CoreUtils.wait(retryAfter); return this.waitElementToExist(findFunction, retries - 1); } return element; } /** * Handle bootstrap tooltips in a certain element. * * @param element Element to check. */ handleBootstrapTooltips(element: HTMLElement): void { const els = Array.from(element.querySelectorAll('[data-toggle="tooltip"]')); els.forEach((el) => { const content = el.getAttribute('title') || el.getAttribute('data-original-title'); const trigger = el.getAttribute('data-trigger') || 'hover focus'; const treated = el.getAttribute('data-bstooltip-treated'); if (!content || treated === 'true' || (trigger.indexOf('hover') == -1 && trigger.indexOf('focus') == -1 && trigger.indexOf('click') == -1)) { return; } el.setAttribute('data-bstooltip-treated', 'true'); // Mark it as treated. // Store the title in data-original-title instead of title, like BS does. el.setAttribute('data-original-title', content); el.setAttribute('title', ''); el.addEventListener('click', async (ev: Event) => { const html = el.getAttribute('data-html'); await CoreDomUtils.openPopover({ component: CoreBSTooltipComponent, componentProps: { content, html: html === 'true', }, event: ev, }); }); }); } /** * Check if an element is outside of screen (viewport). * * @param scrollEl The element that must be scrolled. * @param element DOM element to check. * @param point The point of the element to check. * @returns Whether the element is outside of the viewport. */ isElementOutsideOfScreen( scrollEl: HTMLElement, element: HTMLElement, point: VerticalPoint = VerticalPoint.MID, ): boolean { const elementRect = element.getBoundingClientRect(); if (!elementRect) { return false; } let elementPoint: number; switch (point) { case VerticalPoint.TOP: elementPoint = elementRect.top; break; case VerticalPoint.BOTTOM: elementPoint = elementRect.bottom; break; case VerticalPoint.MID: elementPoint = Math.round((elementRect.bottom + elementRect.top) / 2); break; } const scrollElRect = scrollEl.getBoundingClientRect(); const scrollTopPos = scrollElRect?.top || 0; return elementPoint > window.innerHeight || elementPoint < scrollTopPos; } /** * Check if rich text editor is enabled. * * @returns Promise resolved with boolean: true if enabled, false otherwise. */ async isRichTextEditorEnabled(): Promise { const enabled = await CoreConfig.get(CoreConstants.SETTINGS_RICH_TEXT_EDITOR, true); return !!enabled; } /** * Check if rich text editor is supported in the platform. * * @returns Whether it's supported. * @deprecated since 3.9.5 */ isRichTextEditorSupported(): boolean { return true; } /** * Move children from one HTMLElement to another. * * @param oldParent The old parent. * @param newParent The new parent. * @param prepend If true, adds the children to the beginning of the new parent. * @returns List of moved children. */ moveChildren(oldParent: HTMLElement, newParent: HTMLElement, prepend?: boolean): Node[] { const movedChildren: Node[] = []; const referenceNode = prepend ? newParent.firstChild : null; while (oldParent.childNodes.length > 0) { const child = oldParent.childNodes[0]; movedChildren.push(child); newParent.insertBefore(child, referenceNode); } return movedChildren; } /** * Search and remove a certain element from inside another element. * * @param element DOM element to search in. * @param selector Selector to search. */ removeElement(element: HTMLElement, selector: string): void { const selected = element.querySelector(selector); if (selected) { selected.remove(); } } /** * Search and remove a certain element from an HTML code. * * @param html HTML code to change. * @param selector Selector to search. * @param removeAll True if it should remove all matches found, false if it should only remove the first one. * @returns HTML without the element. */ removeElementFromHtml(html: string, selector: string, removeAll?: boolean): string { const element = this.convertToElement(html); if (removeAll) { const selected = element.querySelectorAll(selector); for (let i = 0; i < selected.length; i++) { selected[i].remove(); } } else { const selected = element.querySelector(selector); if (selected) { selected.remove(); } } return element.innerHTML; } /** * Remove a component/directive instance using the DOM Element. * * @param element The root element of the component/directive. * @deprecated since 4.0.0. It's no longer necessary to remove instances. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars removeInstanceByElement(element: Element): void { // } /** * Remove a component/directive instance using the ID. * * @param id The ID to remove. * @deprecated since 4.0.0. It's no longer necessary to remove instances. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars removeInstanceById(id: string): void { // } /** * Search for certain classes in an element contents and replace them with the specified new values. * * @param element DOM element. * @param map Mapping of the classes to replace. Keys must be the value to replace, values must be * the new class name. Example: {'correct': 'core-question-answer-correct'}. */ replaceClassesInElement(element: HTMLElement, map: {[currentValue: string]: string}): void { for (const key in map) { const foundElements = element.querySelectorAll('.' + key); for (let i = 0; i < foundElements.length; i++) { const foundElement = foundElements[i]; foundElement.className = foundElement.className.replace(key, map[key]); } } } /** * Given an HTML, search all links and media and tries to restore original sources using the paths object. * * @param html HTML code. * @param paths Object linking URLs in the html code with the real URLs to use. * @param anchorFn Function to call with each anchor. Optional. * @returns Treated HTML code. */ restoreSourcesInHtml( html: string, paths: {[url: string]: string}, anchorFn?: (anchor: HTMLElement, href: string) => void, ): string { const element = this.convertToElement(html); // Treat elements with src (img, audio, video, ...). const media = Array.from(element.querySelectorAll('img, video, audio, source, track')); media.forEach((media: HTMLElement) => { const currentSrc = media.getAttribute('src'); const newSrc = currentSrc ? paths[CoreUrlUtils.removeUrlParams(CoreTextUtils.decodeURIComponent(currentSrc))] : undefined; if (newSrc !== undefined) { media.setAttribute('src', newSrc); } // Treat video posters. const currentPoster = media.getAttribute('poster'); if (media.tagName == 'VIDEO' && currentPoster) { const newPoster = paths[CoreTextUtils.decodeURIComponent(currentPoster)]; if (newPoster !== undefined) { media.setAttribute('poster', newPoster); } } }); // Now treat links. const anchors = Array.from(element.querySelectorAll('a')); anchors.forEach((anchor: HTMLElement) => { const currentHref = anchor.getAttribute('href'); const newHref = currentHref ? paths[CoreUrlUtils.removeUrlParams(CoreTextUtils.decodeURIComponent(currentHref))] : undefined; if (newHref !== undefined) { anchor.setAttribute('href', newHref); if (typeof anchorFn == 'function') { anchorFn(anchor, newHref); } } }); return element.innerHTML; } /** * Scroll to somehere in the content. * * @param content Content to scroll. * @param x The x-value to scroll to. * @param y The y-value to scroll to. * @param duration Duration of the scroll animation in milliseconds. * @returns Returns a promise which is resolved when the scroll has completed. * @deprecated since 3.9.5. Use directly the IonContent class. */ scrollTo(content: IonContent, x: number, y: number, duration = 0): Promise { return content.scrollToPoint(x, y, duration); } /** * Scroll to Bottom of the content. * * @param content Content to scroll. * @param duration Duration of the scroll animation in milliseconds. * @returns Returns a promise which is resolved when the scroll has completed. * @deprecated since 3.9.5. Use directly the IonContent class. */ scrollToBottom(content: IonContent, duration = 0): Promise { return content.scrollToBottom(duration); } /** * Scroll to Top of the content. * * @param content Content to scroll. * @param duration Duration of the scroll animation in milliseconds. * @returns Returns a promise which is resolved when the scroll has completed. * @deprecated since 3.9.5. Use directly the IonContent class. */ scrollToTop(content: IonContent, duration = 0): Promise { return content.scrollToTop(duration); } /** * Returns height of the content. * * @param content Content where to execute the function. * @returns Promise resolved with content height. */ async getContentHeight(content: IonContent): Promise { try { const scrollElement = await content.getScrollElement(); return scrollElement.clientHeight || 0; } catch { return 0; } } /** * Returns scroll height of the content. * * @param content Content where to execute the function. * @returns Promise resolved with scroll height. */ async getScrollHeight(content: IonContent): Promise { try { const scrollElement = await content.getScrollElement(); return scrollElement.scrollHeight || 0; } catch { return 0; } } /** * Returns scrollTop of the content. * * @param content Content where to execute the function. * @returns Promise resolved with scroll top. */ async getScrollTop(content: IonContent): Promise { try { const scrollElement = await content.getScrollElement(); return scrollElement.scrollTop || 0; } catch { return 0; } } /** * Scroll to a certain element. * * @param content Not used anymore. * @param element The element to scroll to. * @param scrollParentClass Not used anymore. * @param duration Duration of the scroll animation in milliseconds. * @returns True if the element is found, false otherwise. * @deprecated since app 4.0 Use CoreDom.scrollToElement instead. */ scrollToElement(content: IonContent, element: HTMLElement, scrollParentClass?: string, duration?: number): boolean { CoreDom.scrollToElement(element, undefined, { duration }); return true; } /** * Scroll to a certain element using a selector to find it. * * @param container The element that contains the element that must be scrolled. * @param content Not used anymore. * @param selector Selector to find the element to scroll to. * @param scrollParentClass Not used anymore. * @param duration Duration of the scroll animation in milliseconds. * @returns True if the element is found, false otherwise. * @deprecated since app 4.0 Use CoreDom.scrollToElement instead. */ scrollToElementBySelector( container: HTMLElement | null, content: unknown | null, selector: string, scrollParentClass?: string, duration?: number, ): boolean { if (!container || !content) { return false; } CoreDom.scrollToElement(container, selector, { duration }); return true; } /** * 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. * @deprecated since app 4.0 Use CoreDom.scrollToInputError instead. */ scrollToInputError(container: HTMLElement | null): boolean { if (!container) { return false; } CoreDom.scrollToInputError(container); return true; } /** * Set whether debug messages should be displayed. * * @param value Whether to display or not. */ setDebugDisplay(value: boolean): void { this.debugDisplay = value; } /** * Show an alert modal with a button to close it. * * @param header Title to show. * @param message Message to show. * @param buttonText Text of the button. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @returns Promise resolved with the alert modal. */ async showAlert( header: string | undefined, message: string, buttonText?: string, autocloseTime?: number, ): Promise { return this.showAlertWithOptions({ header, message, buttons: [buttonText || Translate.instant('core.ok')], }, autocloseTime); } /** * General show an alert modal. * * @param options Alert options to pass to the alert. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @returns Promise resolved with the alert modal. */ async showAlertWithOptions(options: AlertOptions = {}, autocloseTime?: number): Promise { const hasHTMLTags = CoreTextUtils.hasHTMLTags( options.message || ''); if (hasHTMLTags && !CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('3.7')) { // Treat multilang. options.message = await AddonFilterMultilangHandler.filter( options.message); } const alertId = Md5.hashAsciiStr((options.header || '') + '#' + (options.message || '')); if (this.displayedAlerts[alertId]) { // There's already an alert with the same message and title. Return it. return this.displayedAlerts[alertId]; } const alert = await AlertController.create(options); if (Object.keys(this.displayedAlerts).length === 0) { await Promise.all(this.activeLoadingModals.slice(0).reverse().map(modal => modal.pause())); } // eslint-disable-next-line promise/catch-or-return alert.present().then(() => { if (hasHTMLTags) { // Treat all anchors so they don't override the app. const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); alertMessageEl && this.treatAnchors(alertMessageEl); } return; }); // Store the alert and remove it when dismissed. this.displayedAlerts[alertId] = alert; // Set the callbacks to trigger an observable event. // eslint-disable-next-line promise/catch-or-return alert.onDidDismiss().then(async () => { delete this.displayedAlerts[alertId]; // eslint-disable-next-line promise/always-return if (Object.keys(this.displayedAlerts).length === 0) { await Promise.all(this.activeLoadingModals.map(modal => modal.resume())); } }); if (autocloseTime && autocloseTime > 0) { setTimeout(async () => { await alert.dismiss(); if (options.buttons) { // Execute dismiss function if any. const cancelButton = options.buttons.find( (button) => typeof button != 'string' && button.handler !== undefined && button.role == 'cancel', ); cancelButton?.handler?.(null); } }, autocloseTime); } return alert; } /** * Show an alert modal with a button to close it, translating the values supplied. * * @param header Title to show. * @param message Message to show. * @param buttonText Text of the button. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @returns Promise resolved with the alert modal. */ showAlertTranslated( header: string | undefined, message: string, buttonText?: string, autocloseTime?: number, ): Promise { header = header ? Translate.instant(header) : header; message = message ? Translate.instant(message) : message; buttonText = buttonText ? Translate.instant(buttonText) : buttonText; return this.showAlert(header, message, buttonText, autocloseTime); } /** * Shortcut for a delete confirmation modal. * * @param translateMessage String key to show in the modal body translated. Default: 'core.areyousure'. * @param translateArgs Arguments to pass to translate if necessary. * @param options More options. See https://ionicframework.com/docs/v3/api/components/alert/AlertController/ * @returns Promise resolved if the user confirms and rejected with a canceled error if he cancels. */ showDeleteConfirm( translateMessage: string = 'core.areyousure', translateArgs: Record = {}, options: AlertOptions = {}, ): Promise { return new Promise((resolve, reject): void => { options.message = Translate.instant(translateMessage, translateArgs); options.buttons = [ { text: Translate.instant('core.cancel'), role: 'cancel', handler: () => { reject(new CoreCanceledError('')); }, }, { text: Translate.instant('core.delete'), role: 'destructive', handler: () => { resolve(); }, }, ]; if (!options.header) { options.cssClass = (options.cssClass || '') + ' core-nohead'; } this.showAlertWithOptions(options, 0); }); } /** * Show a confirm modal. * * @param message Message to show in the modal body. * @param header Header of the modal. * @param okText Text of the OK button. * @param cancelText Text of the Cancel button. * @param options More options. * @returns Promise resolved if the user confirms and rejected with a canceled error if he cancels. */ showConfirm( message: string, header?: string, okText?: string, cancelText?: string, options: AlertOptions = {}, ): Promise { return new Promise((resolve, reject): void => { options.header = header; options.message = message; options.buttons = [ { text: cancelText || Translate.instant('core.cancel'), role: 'cancel', handler: () => { reject(new CoreCanceledError('')); }, }, { text: okText || Translate.instant('core.ok'), handler: (data: T) => { resolve(data); }, }, ]; if (!header) { options.cssClass = (options.cssClass || '') + ' core-nohead'; } this.showAlertWithOptions(options, 0); }); } /** * Show an alert modal with an error message. * * @param error Message to show. * @param needsTranslate Whether the error needs to be translated. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @returns Promise resolved with the alert modal. */ async showErrorModal( error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean, autocloseTime?: number, ): Promise { if (this.isCanceledError(error)) { // It's a canceled error, don't display an error. return null; } const message = this.getErrorMessage(error, needsTranslate); if (message === null) { // Message doesn't need to be displayed, stop. return null; } const alertOptions: AlertOptions = { message }; if (this.isNetworkError(message, error)) { alertOptions.cssClass = 'core-alert-network-error'; } if (typeof error !== 'string' && 'title' in error && error.title) { alertOptions.header = error.title || undefined; } else if (message === Translate.instant('core.sitenotfoundhelp')) { alertOptions.header = Translate.instant('core.sitenotfound'); } else if (this.isSiteUnavailableError(message)) { alertOptions.header = CoreSites.isLoggedIn() ? Translate.instant('core.connectionlost') : Translate.instant('core.cannotconnect'); } else { alertOptions.header = Translate.instant('core.error'); } if (typeof error !== 'string' && 'buttons' in error && typeof error.buttons !== 'undefined') { alertOptions.buttons = error.buttons; } else if (error instanceof CoreSiteError) { if (error.errorDetails) { alertOptions.message = `

${alertOptions.message}

`; } const supportConfig = error.supportConfig; alertOptions.buttons = [Translate.instant('core.ok')]; if (supportConfig?.canContactSupport()) { alertOptions.buttons.push({ text: Translate.instant('core.contactsupport'), handler: () => CoreUserSupport.contact({ supportConfig, subject: alertOptions.header, message: `${error.errorcode}\n\n${error.errorDetails}`, }), }); } } else { alertOptions.buttons = [Translate.instant('core.ok')]; } const alertElement = await this.showAlertWithOptions(alertOptions, autocloseTime); if (error instanceof CoreSiteError && error.errorDetails) { const containerElement = alertElement.querySelector('.core-error-info-container'); if (containerElement) { containerElement.innerHTML = CoreErrorInfoComponent.render(error.errorDetails, error.errorcode); } } return alertElement; } /** * Show an alert modal with an error message. It uses a default message if error is not a string. * * @param error Message to show. * @param defaultError Message to show if the error is not a string. * @param needsTranslate Whether the error needs to be translated. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @returns Promise resolved with the alert modal. */ async showErrorModalDefault( error: CoreAnyError, defaultError: string, needsTranslate = false, autocloseTime?: number, ): Promise { if (this.isCanceledError(error) || this.isSilentError(error)) { // It's a canceled or a silent error, don't display an error. return null; } let errorMessage = error || undefined; if (error && typeof error != 'string') { errorMessage = CoreTextUtils.getErrorMessageFromError(error); } return this.showErrorModal( typeof errorMessage == 'string' && errorMessage ? error! : defaultError, needsTranslate, autocloseTime, ); } /** * Show an alert modal with the first warning error message. It uses a default message if error is not a string. * * @param warnings Warnings returned. * @param defaultError Message to show if the error is not a string. * @param needsTranslate Whether the error needs to be translated. * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @returns Promise resolved with the alert modal. */ showErrorModalFirstWarning( warnings: CoreWSExternalWarning[], defaultError: string, needsTranslate?: boolean, autocloseTime?: number, ): Promise { return this.showErrorModalDefault(warnings?.[0], defaultError, needsTranslate, autocloseTime); } /** * Displays a loading modal window. * * @param text The text of the modal window. Default: core.loading. * @param needsTranslate Whether the 'text' needs to be translated. * @returns Loading element instance. * @description * Usage: * let modal = await domUtils.showModalLoading(myText); * ... * modal.dismiss(); */ async showModalLoading(text?: string, needsTranslate?: boolean): Promise { if (!text) { text = Translate.instant('core.loading'); } else if (needsTranslate) { text = Translate.instant(text); } const loading = new CoreIonLoadingElement(text); loading.onDismiss(() => { const index = this.activeLoadingModals.indexOf(loading); if (index !== -1) { this.activeLoadingModals.splice(index, 1); } }); this.activeLoadingModals.push(loading); await loading.present(); return loading; } /** * Show a modal warning the user that he should use a different app. * * @param message The warning message. * @param link Link to the app to download if any. * @returns Promise resolved when done. */ async showDownloadAppNoticeModal(message: string, link?: string): Promise { const buttons: AlertButton[] = [{ text: Translate.instant('core.ok'), role: 'cancel', }]; if (link) { buttons.push({ text: Translate.instant('core.download'), handler: (): void => { CoreUtils.openInBrowser(link, { showBrowserWarning: false }); }, }); } const alert = await this.showAlertWithOptions({ message: message, buttons: buttons, }); const isDevice = CorePlatform.isAndroid() || CorePlatform.isIOS(); if (!isDevice) { // Treat all anchors so they don't override the app. const alertMessageEl: HTMLElement | null = alert.querySelector('.alert-message'); alertMessageEl && this.treatAnchors(alertMessageEl); } await alert.onDidDismiss(); } /** * Show a prompt modal to input some data. * * @param message Modal message. * @param header Modal header. * @param placeholderOrLabel Placeholder (for textual/numeric inputs) or label (for radio/checkbox). By default, "Password". * @param type Type of the input element. By default, password. * @param buttons Buttons. If not provided or it's an object with texts, OK and Cancel buttons will be displayed. * @param options Other alert options. * @returns Promise resolved with the input data (true for checkbox/radio) if the user clicks OK, rejected if cancels. */ showPrompt( message: string, header?: string, placeholderOrLabel?: string, type: TextFieldTypes | 'checkbox' | 'radio' | 'textarea' = 'password', buttons?: PromptButton[] | { okText?: string; cancelText?: string }, options: AlertOptions = {}, ): Promise { // eslint-disable-line @typescript-eslint/no-explicit-any return new Promise((resolve, reject) => { placeholderOrLabel = placeholderOrLabel ?? Translate.instant('core.login.password'); const isCheckbox = type === 'checkbox'; const isRadio = type === 'radio'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const resolvePromise = (data: any) => { if (isCheckbox) { resolve(data[0]); } else if (isRadio) { resolve(data); } else { resolve(data.promptinput); } }; options.header = header; options.message = message; options.inputs = [ { name: 'promptinput', placeholder: placeholderOrLabel, label: placeholderOrLabel, type, value: (isCheckbox || isRadio) ? true : undefined, }, ]; if (Array.isArray(buttons) && buttons.length) { options.buttons = buttons.map((button) => ({ ...button, handler: (data) => { if (!button.handler) { // Just resolve the promise. resolvePromise(data); return; } button.handler(data, resolve, reject); }, })); } else { // Default buttons. options.buttons = [ { text: buttons && 'cancelText' in buttons ? buttons.cancelText as string : Translate.instant('core.cancel'), role: 'cancel', handler: () => { reject(); }, }, { text: buttons && 'okText' in buttons ? buttons.okText as string : Translate.instant('core.ok'), handler: resolvePromise, }, ]; } this.showAlertWithOptions(options); }); } /** * Show a prompt modal to input a textarea. * * @param title Modal title. * @param message Modal message. * @param buttons Buttons to pass to the modal. * @param placeholder Placeholder of the input element if any. * @returns Promise resolved with the entered text if any. */ async showTextareaPrompt( title: string, message: string, buttons: AlertButton[], placeholder?: string, ): Promise { const alert = await AlertController.create({ header: title, message, inputs: [ { name: 'textarea-prompt', type: 'textarea', placeholder: placeholder, }, ], buttons, }); await alert.present(); const result = await alert.onWillDismiss(); if (result.role === 'cancel') { return; } return result.data?.values?.['textarea-prompt']; } /** * Displays an autodimissable toast modal window. * * @param text The text of the toast. * @param needsTranslate Whether the 'text' needs to be translated. * @param duration Duration in ms of the dimissable toast. * @param cssClass Class to add to the toast. * @returns Toast instance. */ async showToast( text: string, needsTranslate?: boolean, duration: ToastDuration | number = ToastDuration.SHORT, cssClass: string = '', ): Promise { if (needsTranslate) { text = Translate.instant(text); } return this.showToastWithOptions({ message: text, duration: duration, position: 'bottom', cssClass: cssClass, }); } /** * Show toast with some options. * * @param options Options. * @returns Promise resolved with Toast instance. */ async showToastWithOptions(options: ShowToastOptions): Promise { // Convert some values and set default values. const toastOptions: ToastOptions = { ...options, duration: CoreConstants.CONFIG.toastDurations[options.duration] ?? options.duration ?? 2000, position: options.position ?? 'bottom', }; const loader = await ToastController.create(toastOptions); await loader.present(); return loader; } /** * Stores a component/directive instance. * * @param element The root element of the component/directive. * @param instance The instance to store. * @deprecated since 4.0.0. Use CoreDirectivesRegistry instead. */ storeInstanceByElement(element: Element, instance: unknown): void { CoreDirectivesRegistry.register(element, instance); } /** * Check if an element supports input via keyboard. * * @param el HTML element to check. * @returns Whether it supports input using keyboard. */ supportsInputKeyboard(el: HTMLElement): boolean { return el && !( el).disabled && (el.tagName.toLowerCase() == 'textarea' || (el.tagName.toLowerCase() == 'input' && this.INPUT_SUPPORT_KEYBOARD.indexOf(( el).type) != -1)); } /** * Converts HTML formatted text to DOM element(s). * * @param text HTML text. * @returns Same text converted to HTMLCollection. */ toDom(text: string): HTMLCollection { const element = this.convertToElement(text); return element.children; } /** * Treat anchors inside alert/modals. * * @param container The HTMLElement that can contain anchors. */ treatAnchors(container: HTMLElement): void { const anchors = Array.from(container.querySelectorAll('a')); anchors.forEach((anchor) => { anchor.addEventListener('click', (event) => { if (event.defaultPrevented) { // Stop. return; } const href = anchor.getAttribute('href'); if (href) { event.preventDefault(); event.stopPropagation(); CoreUtils.openInBrowser(href); } }); }); } /** * Opens a Modal. * * @param options Modal Options. * @returns The modal data when the modal closes. */ async openModal( options: OpenModalOptions, ): Promise { const { waitForDismissCompleted, closeOnNavigate, ...modalOptions } = options; const listenCloseEvents = closeOnNavigate ?? true; // Default to true. // TODO: Improve this if we need two modals with same component open at the same time. const modalId = Md5.hashAsciiStr(options.component?.toString() || ''); const modal = this.displayedModals[modalId] ? this.displayedModals[modalId] : await ModalController.create(modalOptions); let navSubscription: Subscription | undefined; // Get the promise before presenting to get result if modal is suddenly hidden. const resultPromise = waitForDismissCompleted ? modal.onDidDismiss() : modal.onWillDismiss(); if (!this.displayedModals[modalId]) { // Store the modal and remove it when dismissed. this.displayedModals[modalId] = modal; if (listenCloseEvents) { // Listen navigation events to close modals. navSubscription = Router.events .pipe(filter(event => event instanceof NavigationStart)) .subscribe(async () => { modal.dismiss(); }); } await modal.present(); } const result = await resultPromise; navSubscription?.unsubscribe(); delete this.displayedModals[modalId]; if (result?.data) { return result?.data; } } /** * Opens a side Modal. * * @param options Modal Options. * @returns The modal data when the modal closes. */ async openSideModal( options: OpenModalOptions, ): Promise { options = Object.assign({ cssClass: 'core-modal-lateral', showBackdrop: true, backdropDismiss: true, enterAnimation: CoreModalLateralTransitionEnter, leaveAnimation: CoreModalLateralTransitionLeave, }, options); return this.openModal(options); } /** * Opens a popover. * * @param options Options. * @returns Promise resolved when the popover is dismissed or will be dismissed. */ async openPopover(options: OpenPopoverOptions): Promise { const { waitForDismissCompleted, ...popoverOptions } = options; const popover = await PopoverController.create(popoverOptions); const zoomLevel = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreConstants.CONFIG.defaultZoomLevel); await popover.present(); // Fix popover position if zoom is applied. if (zoomLevel !== CoreZoomLevel.NONE) { switch (getMode()) { case 'ios': fixIOSPopoverPosition(popover, options.event); break; case 'md': fixMDPopoverPosition(popover, options.event); break; } } const result = waitForDismissCompleted ? await popover.onDidDismiss() : await popover.onWillDismiss(); if (result?.data) { return result?.data; } } /** * View an image in a modal. * * @param image URL of the image. * @param title Title of the page or modal. * @param component Component to link the image to if needed. * @param componentId An ID to use in conjunction with the component. */ async viewImage( image: string, title?: string | null, component?: string, componentId?: string | number, ): Promise { if (!image) { return; } await CoreDomUtils.openModal({ component: CoreViewerImageComponent, componentProps: { title, image, component, componentId, }, cssClass: 'core-modal-transparent', }); } /** * Wait for images to load. * * @param element The element to search in. * @returns Promise resolved with a boolean: whether there was any image to load. */ async waitForImages(element: HTMLElement): Promise { const imgs = Array.from(element.querySelectorAll('img')); const promises: Promise[] = []; let hasImgToLoad = false; imgs.forEach((img) => { if (img && !img.complete) { hasImgToLoad = true; // Wait for image to load or fail. promises.push(new Promise((resolve) => { const imgLoaded = (): void => { resolve(); img.removeEventListener('load', imgLoaded); img.removeEventListener('error', imgLoaded); }; img.addEventListener('load', imgLoaded); img.addEventListener('error', imgLoaded); })); } }); await Promise.all(promises); return hasImgToLoad; } /** * Wrap an HTMLElement with another element. * * @param el The element to wrap. * @param wrapper Wrapper. */ wrapElement(el: HTMLElement, wrapper: HTMLElement): void { // Insert the wrapper before the element. el.parentNode?.insertBefore(wrapper, el); // Now move the element into the wrapper. wrapper.appendChild(el); } /** * Trigger form cancelled event. * * @param formRef Form element. * @param siteId The site affected. If not provided, no site affected. * @deprecated since 3.9.5. Function has been moved to CoreForms. */ triggerFormCancelledEvent(formRef: ElementRef | HTMLFormElement | undefined, siteId?: string): void { CoreForms.triggerFormCancelledEvent(formRef, siteId); } /** * Trigger form submitted event. * * @param formRef Form element. * @param online Whether the action was done in offline or not. * @param siteId The site affected. If not provided, no site affected. * @deprecated since 3.9.5. Function has been moved to CoreForms. */ triggerFormSubmittedEvent(formRef: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void { CoreForms.triggerFormSubmittedEvent(formRef, online, siteId); } /** * In iOS the resize event is triggered before the window size changes. Wait for the size to change. * Use of this function is discouraged. Please use CoreDom.onWindowResize to check window resize event. * * @param windowWidth Initial window width. * @param windowHeight Initial window height. * @param retries Number of retries done. * @returns Promise resolved when done. */ async waitForResizeDone(windowWidth?: number, windowHeight?: number, retries = 0): Promise { if (!CorePlatform.isIOS()) { return; // Only wait in iOS. } windowWidth = windowWidth || window.innerWidth; windowHeight = windowHeight || window.innerHeight; if (windowWidth != window.innerWidth || windowHeight != window.innerHeight || retries >= 10) { // Window size changed or max number of retries reached, stop. return; } // Wait a bit and try again. await CoreUtils.wait(50); return this.waitForResizeDone(windowWidth, windowHeight, retries+1); } /** * Check whether a CSS class indicating an app mode is set. * * @param className Class name. * @returns Whether the CSS class is set. */ hasModeClass(className: string): boolean { return document.documentElement.classList.contains(className); } /** * Get active mode CSS classes. * * @returns Mode classes. */ getModeClasses(): string[] { return Array.from(document.documentElement.classList); } /** * Toggle a CSS class in the root element used to indicate app modes. * * @param className Class name. * @param enable Whether to add or remove the class. */ toggleModeClass(className: string, enable?: boolean): void { document.documentElement.classList.toggle(className, enable); // @deprecated since 4.1 document.body.classList.toggle(className, enable); } } /** * Fix the position of a popover that was created with a zoom level applied in iOS. * * This is necessary because Ionic's implementation gets the body dimensions from `element.ownerDocument.defaultView.innerXXX`, * which doesn't return the correct dimensions when the `zoom` CSS property is being used. This is specially necessary * in iOS because Android already respects system font sizes. Eventually, we should find an alternative implementation for iOS * that doesn't require this workaround (also because the `zoom` CSS property is not standard and its usage is discouraged for * production). * * This function has been copied in its entirety from Ionic's source code, only changing the aforementioned calculation * of the body dimensions with `document.body.clientXXX`. * * @see https://github.com/ionic-team/ionic-framework/blob/v5.6.6/core/src/components/popover/animations/ios.enter.ts */ function fixIOSPopoverPosition(baseEl: HTMLElement, ev?: Event): void { let originY = 'top'; let originX = 'left'; const POPOVER_IOS_BODY_PADDING = 5; const contentEl = baseEl.querySelector('.popover-content') as HTMLElement; const contentDimentions = contentEl.getBoundingClientRect(); const contentWidth = contentDimentions.width; const contentHeight = contentDimentions.height; const bodyWidth = document.body.clientWidth; const bodyHeight = document.body.clientHeight; const targetDim = ev && ev.target && (ev.target as HTMLElement).getBoundingClientRect(); const targetTop = targetDim != null && 'top' in targetDim ? targetDim.top : bodyHeight / 2 - contentHeight / 2; const targetLeft = targetDim != null && 'left' in targetDim ? targetDim.left : bodyWidth / 2; const targetWidth = (targetDim && targetDim.width) || 0; const targetHeight = (targetDim && targetDim.height) || 0; const arrowEl = baseEl.querySelector('.popover-arrow') as HTMLElement; const arrowDim = arrowEl.getBoundingClientRect(); const arrowWidth = arrowDim.width; const arrowHeight = arrowDim.height; if (targetDim == null) { arrowEl.style.display = 'none'; } const arrowCSS = { top: targetTop + targetHeight, left: targetLeft + targetWidth / 2 - arrowWidth / 2, }; const popoverCSS: { top: number; left: number } = { top: targetTop + targetHeight + (arrowHeight - 1), left: targetLeft + targetWidth / 2 - contentWidth / 2, }; let checkSafeAreaLeft = false; let checkSafeAreaRight = false; if (popoverCSS.left < POPOVER_IOS_BODY_PADDING + 25) { checkSafeAreaLeft = true; popoverCSS.left = POPOVER_IOS_BODY_PADDING; } else if ( contentWidth + POPOVER_IOS_BODY_PADDING + popoverCSS.left + 25 > bodyWidth ) { checkSafeAreaRight = true; popoverCSS.left = bodyWidth - contentWidth - POPOVER_IOS_BODY_PADDING; originX = 'right'; } if (targetTop + targetHeight + contentHeight > bodyHeight && targetTop - contentHeight > 0) { arrowCSS.top = targetTop - (arrowHeight + 1); popoverCSS.top = targetTop - contentHeight - (arrowHeight - 1); baseEl.className = baseEl.className + ' popover-bottom'; originY = 'bottom'; } else if (targetTop + targetHeight + contentHeight > bodyHeight) { contentEl.style.bottom = POPOVER_IOS_BODY_PADDING + '%'; } arrowEl.style.top = arrowCSS.top + 'px'; arrowEl.style.left = arrowCSS.left + 'px'; contentEl.style.top = popoverCSS.top + 'px'; contentEl.style.left = popoverCSS.left + 'px'; if (checkSafeAreaLeft) { contentEl.style.left = `calc(${popoverCSS.left}px + var(--ion-safe-area-left, 0px))`; } if (checkSafeAreaRight) { contentEl.style.left = `calc(${popoverCSS.left}px - var(--ion-safe-area-right, 0px))`; } contentEl.style.transformOrigin = originY + ' ' + originX; } /** * Fix the position of a popover that was created with a zoom level applied in Android. * * This is necessary because Ionic's implementation gets the body dimensions from `element.ownerDocument.defaultView.innerXXX`, * which doesn't return the correct dimensions when the `zoom` CSS property is being used. This is only a temporary solution * in Android because system zooming is already supported, so it won't be necessary to do it at an app level. * * @todo MOBILE-3790 remove the ability to zoom in Android. * * This function has been copied in its entirety from Ionic's source code, only changing the aforementioned calculation * of the body dimensions with `document.body.clientXXX`. * * @see https://github.com/ionic-team/ionic-framework/blob/v5.6.6/core/src/components/popover/animations/md.enter.ts */ function fixMDPopoverPosition(baseEl: HTMLElement, ev?: Event): void { const POPOVER_MD_BODY_PADDING = 12; const isRTL = document.dir === 'rtl'; let originY = 'top'; let originX = isRTL ? 'right' : 'left'; const contentEl = baseEl.querySelector('.popover-content') as HTMLElement; const contentDimentions = contentEl.getBoundingClientRect(); const contentWidth = contentDimentions.width; const contentHeight = contentDimentions.height; const bodyWidth = document.body.clientWidth; const bodyHeight = document.body.clientHeight; const targetDim = ev && ev.target && (ev.target as HTMLElement).getBoundingClientRect(); const targetTop = targetDim != null && 'bottom' in targetDim ? targetDim.bottom : bodyHeight / 2 - contentHeight / 2; const targetLeft = targetDim != null && 'left' in targetDim ? isRTL ? targetDim.left - contentWidth + targetDim.width : targetDim.left : bodyWidth / 2 - contentWidth / 2; const targetHeight = (targetDim && targetDim.height) || 0; const popoverCSS: { top: number; left: number } = { top: targetTop, left: targetLeft, }; if (popoverCSS.left < POPOVER_MD_BODY_PADDING) { popoverCSS.left = POPOVER_MD_BODY_PADDING; originX = 'left'; } else if (contentWidth + POPOVER_MD_BODY_PADDING + popoverCSS.left > bodyWidth) { popoverCSS.left = bodyWidth - contentWidth - POPOVER_MD_BODY_PADDING; originX = 'right'; } if (targetTop + targetHeight + contentHeight > bodyHeight && targetTop - contentHeight > 0) { popoverCSS.top = targetTop - contentHeight - targetHeight; baseEl.className = baseEl.className + ' popover-bottom'; originY = 'bottom'; } else if (targetTop + targetHeight + contentHeight > bodyHeight) { contentEl.style.bottom = POPOVER_MD_BODY_PADDING + 'px'; } contentEl.style.top = popoverCSS.top + 'px'; contentEl.style.left = popoverCSS.left + 'px'; contentEl.style.transformOrigin = originY + ' ' + originX; } export const CoreDomUtils = makeSingleton(CoreDomUtilsProvider); /** * Options for the openPopover function. */ export type OpenPopoverOptions = PopoverOptions & { waitForDismissCompleted?: boolean; }; /** * Options for the openModal function. */ export type OpenModalOptions = ModalOptions & { waitForDismissCompleted?: boolean; closeOnNavigate?: boolean; // Default true. }; /** * Buttons for prompt alert. */ export type PromptButton = Omit & { // eslint-disable-next-line @typescript-eslint/no-explicit-any handler?: (value: any, resolve: (value: any) => void, reject: (reason: any) => void) => void; }; /** * Vertical points for an element. */ export enum VerticalPoint { TOP = 'top', MID = 'mid', BOTTOM = 'bottom', } /** * Toast duration. */ export enum ToastDuration { LONG = 'long', SHORT = 'short', STICKY = 'sticky', } /** * Options for showToastWithOptions. */ export type ShowToastOptions = Omit & { duration: ToastDuration | number; };