import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import { latinize } from '../extensions/string-extensions'; import TemporalUtils from './temporal-utils'; export class PortalUtils { private static _isTouchDeviceVal: boolean; private static _isChromeBrowser: boolean; private static _treatAsMobileDeviceVal: boolean; /** * Gets if current site is run inside an iframe */ static isInIframe(): boolean { try { return globalState.self !== globalState.top; } catch (e) { return true; } } /** * Determines if current device runs iOS */ static isIOS(): boolean { let retVal: boolean; if (globalState.windowExists) { retVal = ((/iPad|iPhone|iPod/.test(navigator.userAgent) && !globalState.MSStream) || navigator.userAgent.match(/(iPad)/) != null || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)); } else { retVal = false; } if (retVal) { PortalUtils.isIOS = () => true; } else { PortalUtils.isIOS = () => false; } return retVal; } /** * Determines if current device runs Android */ static isAndroid(): boolean { let retVal: boolean; if (globalState.windowExists) { retVal = navigator.userAgent.toLowerCase().includes('android'); } else { retVal = false; } if (retVal) { PortalUtils.isAndroid = () => true; } else { PortalUtils.isAndroid = () => false; } return retVal; } /** * Determines if we run inside PWA [progressive web app] */ static isPWA() { let retVal: boolean; if (globalState.windowExists) { retVal = window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: fullscreen)').matches || window.matchMedia('(display-mode: minimal-ui)').matches || (window.navigator as any).standalone === true; // iOS } else { retVal = false; } if (retVal) { PortalUtils.isPWA = () => true; } else { PortalUtils.isPWA = () => false; } return retVal; } /** * Determines if given variable is a string * @param obj */ static isString(obj: any): boolean { return typeof obj === 'string' || obj instanceof String; } /** * Determines if given variable is a number * * @param numberToCheck Possible function */ static isNumber(numberToCheck: any): boolean { return !isNaN(parseFloat(numberToCheck)) && isFinite(numberToCheck); } /** * Determines if given variable is a function * * @param functionToCheck Possible function */ static isFunction(functionToCheck: any): boolean { return functionToCheck && {}.toString.call(functionToCheck) === '[object Function]'; } /** * Determines if given variable is Array * @param arr Possible array */ static isArray(arr: any): boolean { return arr && Object.prototype.toString.call(arr) === '[object Array]'; } /** * Determines if current browser is Chrome */ static isBrowserChrome(): boolean { let retVal: boolean; if (globalState.windowExists) { retVal = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); } else { retVal = false; } if (retVal) { PortalUtils.isBrowserChrome = () => true; } else { PortalUtils.isBrowserChrome = () => false; } return retVal; } /** * Performs normalization / unification for search */ static normalizeStringForSearch(str: string): string { return str.toLowerCase()[latinize]().trim(); } /** * Generates random string * * @param length Desired length of the random string */ static randomString(length: number): string { let result = ''; const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; for (let i = length; i > 0; --i) { result += chars[Math.floor(Math.random() * chars.length)]; } return result; } /** * Generates random number * * @param min Min value * @param max Max value */ static randomNumber(min: number, max: number): number { return Math.floor(Math.random() * max) + min; } /** * Downloads a Blob object to a file * @param blob Blob that should be downloaded * @param fileName FileName of the download file */ static downloadBlob( blob: Blob, fileName: string, callback: () => void, ): void { if (!globalState.windowExists) { return; } if ((globalState.navigator as any).msSaveOrOpenBlob) { (globalState.navigator as any).msSaveOrOpenBlob(blob, fileName); callback(); } else { const _OBJECT_URL = URL.createObjectURL(blob); const dummyLink = document.createElement('a'); const randomId = `ifl-${PortalUtils.randomString(10)}`; dummyLink.setAttribute('id', randomId); document.body.append(dummyLink); setTimeout(() => { document.getElementById(randomId).setAttribute('href', _OBJECT_URL); document.getElementById(randomId).setAttribute('download', fileName); setTimeout(() => { document.getElementById(randomId).click(); setTimeout(() => { globalState.URL.revokeObjectURL(_OBJECT_URL); document.body.removeChild(dummyLink); }, 5000); setTimeout(() => { callback(); }, 50); }, 50); }, 50); } } /* * Obtains URL for asset either on CDN, or on local */ static getAssetPath(path: string): string { return PowerduckState.getCdnPath() + path; } /** * Posts Inviton action message to the topmost window listener * @param actionName Unique name of the action * @param data Accompanying action data */ static postActionMessage(actionName: string, data: any): void { globalState.top.postMessage(`INV-${JSON.stringify({ action: actionName, data, })}`, '*'); } /** * Determines width of the scrollbar */ static getScrollbarWidth(): number { if (!globalState.windowExists) { return 0; } const outer = document.createElement('div'); outer.style.visibility = 'hidden'; outer.style.width = '100px'; document.body.appendChild(outer); const widthNoScroll = outer.offsetWidth; // force scrollbars outer.style.overflow = 'scroll'; // add innerdiv const inner = document.createElement('div'); inner.style.width = '100%'; outer.appendChild(inner); const widthWithScroll = inner.offsetWidth; // remove divs outer.parentNode.removeChild(outer); return widthNoScroll - widthWithScroll; } /** * Determines if current device is a touch-enabled device (mobile, tablet, desktop with touchscreen, etc.) */ static isTouchDevice(): boolean { if (!globalState.windowExists) { return false; } if (PortalUtils._isTouchDeviceVal == null) { // QA_AT-336: document.createEvent('TouchEvent') succeeds in every modern engine // (incl. desktop Safari/Chrome), which classified macOS desktops with overlay // scrollbars as mobile (dropdowns opened as bottom sheets). Probe real touch // capability instead. PortalUtils._isTouchDeviceVal = navigator.maxTouchPoints > 0 || 'ontouchstart' in window; } return PortalUtils._isTouchDeviceVal; } static isChromeDesktopBrowser(): boolean { if (PortalUtils._isChromeBrowser == null) { let retVal = false; if (!globalState.windowExists) { PortalUtils._isChromeBrowser = retVal; return false; } const isChromium = globalState.chrome; const winNav = globalState.navigator; const vendorName = winNav.vendor; const isOpera = typeof globalState.opr !== 'undefined'; const isIEedge = winNav.userAgent.includes('Edg'); const isIOSChrome = winNav.userAgent.match('CriOS'); if (isIOSChrome) { retVal = false; } else if (isChromium !== null && typeof isChromium !== 'undefined' && vendorName === 'Google Inc.' && isOpera === false && isIEedge === false) { retVal = true; } else { retVal = false; } PortalUtils._isChromeBrowser = retVal; } return PortalUtils._isChromeBrowser; } /** * Gets if current site is run inside an iframe */ static treatAsMobileDevice(): boolean { if (PortalUtils._treatAsMobileDeviceVal != null) { return PortalUtils._treatAsMobileDeviceVal; } const _retVal = false; if (PortalUtils.isAndroid() || PortalUtils.isIOS()) { PortalUtils._treatAsMobileDeviceVal = true; return PortalUtils._treatAsMobileDeviceVal; } // If the scrollbar is wider than 0, it means the device usually shows scrollbar when needed and it's most likely a desktop computer with a touchscreen if (PortalUtils.getScrollbarWidth() > 0) { PortalUtils._treatAsMobileDeviceVal = false; return false; } PortalUtils._treatAsMobileDeviceVal = PortalUtils.isTouchDevice(); return PortalUtils._treatAsMobileDeviceVal; } /** * Determines if the viewport is currently in landscape orientation * (wider than tall). Not cached — orientation changes with device rotation. */ static isLandscape(): boolean { if (!globalState.windowExists) { return false; } // Physical orientation, NEVER viewport aspect: on iOS Chrome (and other // WKWebView embedders) the layout viewport's height collapses below its // width while the on-screen keyboard is up, so an innerWidth>innerHeight // check reports "landscape" mid-keyboard. That flipped presentation // decisions (ModalConfig.useBottomSheet) while a modal was open — the // re-render then recomputed the modal's class binding without // `modal-bottom-sheet`, and Vue's wholesale class patch wiped Bootstrap's // runtime `show` class with it: invisible display:block modal over a live // backdrop (the mohf "fully blurred, sheet gone" wedge). const orientationType = globalState.screen?.orientation?.type; if (typeof orientationType === 'string') { return orientationType.startsWith('landscape'); } // Legacy physical signal (pre-16.4 iOS lacks screen.orientation, and its // screen.width/height are portrait-fixed regardless of how the device is // held — useless for orientation there). window.orientation is deprecated // but present exactly on those engines: 0/180 portrait, ±90 landscape. const legacyOrientation = globalState.orientation; if (typeof legacyOrientation === 'number') { return Math.abs(legacyOrientation) === 90; } // Old Android (screen dims DO track rotation there); iOS never reaches // this branch thanks to window.orientation above. const screenSize = globalState.screen; if (screenSize != null && screenSize.width > 0 && screenSize.height > 0) { return screenSize.width > screenSize.height; } return globalState.innerWidth > globalState.innerHeight; } /** * Escapes string into HTML-safe string * @param str - String that needs to be HTML-escaped */ static htmlEscape(str: string): string { return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); } /** * Determines if device supports Native share API */ static hasNativeShare(): boolean { return globalState.navigator?.share != null; } /** * Animated scrolls to given element * * @param element HTMLElement to which page should scroll * @param offset Offset of the scroll, negative scrolls a bit up (-80 scrolls up 80px), positive scrolls down (100, scrolls additional 100px down) */ static scrollToElement(element: HTMLElement, offset?: number) { /** * Recursively climbs UP the tree to determine first PARENT node of the scroll element to obtain the scroll target * @param currentElem */ const getScrollTaget = (currentElem: HTMLElement): HTMLElement => { if ( currentElem.scrollTop != 0 || currentElem.nodeName == 'DIALOG' || currentElem.classList.contains('invmodal-root') || currentElem.classList.contains('modal') || currentElem.getAttribute('role') == 'dialog' ) { return currentElem; } else if (currentElem.parentNode == null || currentElem.nodeName == 'HTML') { return currentElem; } else { return getScrollTaget(currentElem.parentElement); } }; const scrollElem = getScrollTaget(element); const target = element.getBoundingClientRect().top + scrollElem.scrollTop + (offset != null ? offset : 0); PortalUtils.scrollElement(scrollElem, target); } /** * Animated scrolls to top */ static scrollToTop() { /** * Recursively climbs DOWN the tree to determine first CHILD node of the scroll element to obtain the scroll target * @param currentElem */ const getScrollTaget = (currentElem: HTMLElement): HTMLElement => { if (currentElem.scrollTop != 0) { return currentElem; } if (currentElem.children != null) { const childLen = currentElem.children.length; if (childLen > 0) { for (let i = 0; i < childLen; i++) { const possibleItem = getScrollTaget(currentElem.children[i]); if (possibleItem != null) { return possibleItem; } } } } return null; }; PortalUtils.scrollElement(getScrollTaget(document.body) || document.body.parentElement, 0); } /** * Animted scrolls element to given position * * @param element HTMLElement which should scroll (usually document.body) * @param scrollPos Target scroll position in px * @param offset Offset of the scroll, negative scrolls a bit up (-80 scrolls up 80px), positive scrolls down (100, scrolls additional 100px down) */ static scrollElement( element: HTMLElement, scrollPos: number, offset?: number, ) { if (!globalState.windowExists) { return; } // Determine if the topmost scroll element should be HTML or BODY element if (element.nodeName == 'HTML' && element.scrollTop == 0) { element.scrollTop = 1; if (element.scrollTop != 1) { element = document.body; } } // Open dialogs should not scroll unless scrolling inside the dialog if (globalState.inviton && globalState.inviton.dialogUtils && globalState.inviton.dialogUtils.dialogIsOpen()) { const nodeName = element.nodeName.toLowerCase(); if (nodeName == 'html' || nodeName == 'body') { return; } } const targetScroll = scrollPos + (offset != null ? offset : 0); PortalUtils._animateScrollTop( element, targetScroll, 1200, ); } /** * Manually animates scrollTop on the given element over `durationMs` using a * "swing" easing curve (same as jQuery.animate's default), which gives a softer * start/end than `behavior: 'smooth'` and lets us specify an exact duration. */ private static _animateScrollTop( element: HTMLElement, targetTop: number, durationMs: number, ) { const startTop = element.scrollTop; const change = targetTop - startTop; if (change === 0) { return; } const startTime = performance.now(); const ease = (t: number) => 0.5 - Math.cos(t * Math.PI) / 2; const step = (now: number) => { const t = Math.min((now - startTime) / durationMs, 1); element.scrollTop = startTop + change * ease(t); if (t < 1) { requestAnimationFrame(step); } }; requestAnimationFrame(step); } static getChildrenByType(context: any, typeName: string): Array { return context.$children.filter(p => p.$options.name == typeName) as any; } static handleMobileMenuClick(): void { try { if (document.documentElement.classList.contains('nav-open') && globalState.innerWidth < 768) { setTimeout(() => { if (globalState._nowDashboard?.misc.navbar_menu_visible == 1) { document.documentElement.classList.remove('nav-open'); globalState._nowDashboard.misc.navbar_menu_visible = 0; setTimeout(() => { document.querySelectorAll('.navbar-toggle').forEach(el => el.classList.remove('toggled')); document.getElementById('bodyClick')?.remove(); }, 550); } }, 350); } } catch (e) { } } static postToNewWindow(url, fieldArr) { let arr: Array<{ name: string; value: string }>; if (fieldArr.constructor === Array) { arr = fieldArr; } else { arr = []; for (const key in fieldArr) { // eslint-disable-next-line no-prototype-builtins if (fieldArr.hasOwnProperty(key)) { arr.push({ name: key, value: fieldArr[key], }); } } } const form = document.createElement('form'); form.id = `frm${TemporalUtils.dateNowMs()}`; form.method = 'POST'; form.action = url; form.target = '_blank'; arr.forEach((field) => { const input = document.createElement('input'); input.type = 'hidden'; input.name = field.name; input.value = field.value; form.appendChild(input); }); document.body.appendChild(form); form.submit(); setTimeout(() => { form.remove(); }, 50); } }