UNPKG

@eclipse-scout/core

Version:
632 lines (558 loc) 22.3 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import {App, EnumObject, InitModelOf, ObjectModel, objects, ObjectWithType, Predicate, scout} from '../index'; import $ from 'jquery'; let instance: Device; export interface DeviceModel extends ObjectModel<Device> { userAgent: string; } export type DeviceSystem = EnumObject<typeof Device.System>; export type DeviceType = EnumObject<typeof Device.Type>; export type DeviceBrowser = EnumObject<typeof Device.Browser>; /** * Provides information about the device and its supported features.<p> * The information is detected lazily. * * @singleton */ export class Device implements DeviceModel, ObjectWithType { declare model: DeviceModel; objectType: string; userAgent: string; features: Record<string, boolean>; system: DeviceSystem; type: DeviceType; browser: DeviceBrowser; browserVersion: number; systemVersion: number; scrollbarWidth: number; constructor(model?: InitModelOf<Device>) { // user agent string from browser this.userAgent = model.userAgent; this.features = {}; this.system = Device.System.UNKNOWN; this.type = Device.Type.DESKTOP; this.browser = Device.Browser.UNKNOWN; this.browserVersion = 0; this.scrollbarWidth = 0; if (this.userAgent) { this._parseSystem(); this._parseSystemVersion(); this._parseBrowser(); this._parseBrowserVersion(); } } static VENDOR_PREFIXES = ['Webkit', 'Moz', 'O', 'ms', 'Khtml'] as const; static Browser = { UNKNOWN: 'Unknown', FIREFOX: 'Firefox', /** * Chromium based: Google Chrome, Microsoft Edge, Brave, Opera */ CHROME: 'Chrome', INTERNET_EXPLORER: 'InternetExplorer', /** * Only Legacy Edge. Chromium based Edge is reported as CHROME */ EDGE: 'Edge', SAFARI: 'Safari' } as const; static System = { UNKNOWN: 'Unknown', IOS: 'IOS', ANDROID: 'ANDROID', WINDOWS: 'WINDOWS' } as const; static Type = { DESKTOP: 'DESKTOP', TABLET: 'TABLET', MOBILE: 'MOBILE' } as const; /** * Called during bootstrap by index.html before the session startup. * * Precalculates the value of some attributes to store them in a static way (and prevent many repeating function calls within loops). */ bootstrap(): JQuery.Promise<any> { // Pre-calculate value and store in a simple property, to prevent many function calls inside loops this.scrollbarWidth = this._detectScrollbarWidth(); this.type = this._detectType(this.userAgent); if (this.isIos()) { this._installActiveHandler(); } if (this._needsIPhoneRotationHack()) { this._fixIPhoneRotationBug(); } return $.resolvedPromise(); } /** * IOs does only trigger :active when touching an element if a touchstart listener is attached * Unfortunately, the :active is also triggered when scrolling, there is no delay. * To fix this we would have to work with a custom active class which will be toggled on touchstart/end */ protected _installActiveHandler() { // eslint-disable-next-line @typescript-eslint/no-empty-function document.addEventListener('touchstart', () => { }, false); } protected _needsIPhoneRotationHack(): boolean { $.log.isDebugEnabled() && $.log.debug('Activating iPhone rotation workaround.'); // iPad does not automatically switch to minimal-ui mode on rotation. // Also, the hack is not necessary if the body is scrollable (which can be achieved with a custom desktop). return this.isIphone() && !this.isStandalone() && $(document.body).css('overflow') === 'hidden'; } /** * The iphone wants to activate the minimal-ui mode when it is rotated to landscape. This would actually be a good thing, but unfortunately it is buggy. * When the device is rotated there will be a white bar visible at the bottom of the screen. * When it is rotated back it may look ok at first but touching an element does not work anymore because the touch-point is about 30px at the wrong location. * <p> * This happens because the height used for layouting the desktop is smaller than it should be. This layouting is triggered by the window resize event, so obviously * the resize event comes too early and no resize event will be triggered when the minimal-ui mode is activated. * <p> * Unfortunately it is also not possible to schedule the relay outing after a rotation because the height does not seem to be reliable. * Even if the window or body size will explicitly be set to the viewport size, there will be a white bar at the bottom, even though the scout desktop is layouted with the correct size. * <p> * Luckily, it is possible to show the address bar programmatically, but we need to wait for the rotation animation to complete. * Since there is no event for that we need to try it several times, sometimes it will work after 150ms, sometimes we have to wait 250ms. * This is quite a hack and will likely break with a future ios release... */ protected _fixIPhoneRotationBug() { $.log.isDebugEnabled() && $.log.debug('Enabling iPhone rotation workaround.'); let orientation = this.orientation(); let count = 0; let docElem = document.documentElement; window.addEventListener('resize', event => { let newOrientation = Device.get().orientation(); if (orientation !== newOrientation) { orientation = newOrientation; count = 0; tryShowAddressBar(); } }); function tryShowAddressBar() { setTimeout(() => { docElem.scrollTop = 0; if (++count < 8) { tryShowAddressBar(); } }, 50); } } orientation(): 'portrait' | 'landscape' { if (window.innerHeight > window.innerWidth) { return 'portrait'; } return 'landscape'; } hasOnScreenKeyboard(): boolean { return this.supportsFeature('_onScreenKeyboard', () => { return this.isIos() || this.isAndroid() || this.isWindowsTabletMode(); }); } /** * Returns if the current browser includes the padding-right-space in the scrollWidth calculations.<br> * Such a browser increases the scrollWidth only if the text-content exceeds the space <i>including</i> the right-padding. * This means the scrollWidth is equal to the clientWidth until the right-padding-space is consumed as well. */ isScrollWidthIncludingPadding(): boolean { return this.isInternetExplorer() || this.isFirefox() || this.isEdge(); } /** * Safari shows a tooltip if ellipsis are displayed due to text truncation. This is fine but, unfortunately, it cannot be prevented. * Because showing two tooltips at the same time (native and custom) is bad, the custom tooltip cannot be displayed. */ isCustomEllipsisTooltipPossible(): boolean { return this.browser !== Device.Browser.SAFARI; } /** * @returns true if the current device is an iPhone. This is more specific than the <code>isIos</code> function * which also includes iPads and iPods. */ isIos(): boolean { return Device.System.IOS === this.system; } isEdge(): boolean { return Device.Browser.EDGE === this.browser; } /** * @returns 'ms-edge' if the current browser is Microsoft Edge */ cssClassForEdge(): 'ms-edge' | '' { return this.isEdge() ? 'ms-edge' : ''; } /** * @returns 'iphone' if the current device is an iPhone */ cssClassForIphone(): 'iphone' | '' { return this.isIphone() ? 'iphone' : ''; } isIphone(): boolean { return this.userAgent.indexOf('iPhone') > -1; } isInternetExplorer(): boolean { return Device.Browser.INTERNET_EXPLORER === this.browser; } isFirefox(): boolean { return Device.Browser.FIREFOX === this.browser; } isChrome(): boolean { return Device.Browser.CHROME === this.browser; } /** * Compared to isIos() this function uses {@link navigator.platform} instead of navigator.userAgent to check whether the app runs on iOS. * Most of the time isIos() is the way to go. * This function was mainly introduced to detect whether it is a real iOS or an emulated one (e.g. using chrome emulator). * @returns true if the platform is iOS, false if not (e.g. if chrome emulator is running) */ isIosPlatform(): boolean { return /iPad|iPhone|iPod/.test(navigator.platform); } isAndroid(): boolean { return Device.System.ANDROID === this.system; } /** * Checks if the device is running Windows 10 or later in "tablet mode". We assume that this is the case when the * _primary_ input mechanism consists of a pointing device of limited accuracy, such as a finger on a touchscreen. * * In Windows 11, the "tablet mode" cannot be explicitly set by the user. Instead, it is automatically turned on * when the keyboard is detached. When the device is docked, the touchscreen can still be used, but it is no longer * the primary input mechanism. */ isWindowsTabletMode(): boolean { return Device.System.WINDOWS === this.system && this.systemVersion >= 10 && window.matchMedia('(pointer: coarse)').matches; } /** * @returns true if {@link navigator.standalone} is true which is the case for iOS home screen mode */ isStandalone(): boolean { return !!window.navigator['standalone']; } /** * This method returns false for all browsers that are known to be unsupported, all others (e.g. unknown engines) are allowed by default. * The supported browser versions are mainly determined by the features needed by Scout (e.g. class syntax, Array.flatMap, IntersectionObserver, Custom CSS Properties, CSS flex-box, queueMicrotask). */ isSupportedBrowser(browser?: DeviceBrowser, version?: number): boolean { browser = scout.nvl(browser, this.browser); version = scout.nvl(version, this.browserVersion); let browsers = Device.Browser; return (browser === browsers.CHROME && version >= 93) || (browser === browsers.FIREFOX && version >= 92) || (browser === browsers.SAFARI && version >= 15.4); } /** * Can not detect type until DOM is ready because we must create a DIV to measure the scrollbars. */ protected _detectType(userAgent: string): DeviceType { if (Device.System.ANDROID === this.system) { if (userAgent.indexOf('Mobile') > -1) { return Device.Type.MOBILE; } return Device.Type.TABLET; } else if (Device.System.IOS === this.system) { if (userAgent.indexOf('iPad') > -1) { return Device.Type.TABLET; } return Device.Type.MOBILE; } else if (this.isWindowsTabletMode()) { return Device.Type.TABLET; } return Device.Type.DESKTOP; } protected _parseSystem() { let userAgent = this.userAgent; if (userAgent.indexOf('iPhone') > -1 || userAgent.indexOf('iPad') > -1) { this.system = Device.System.IOS; } else if (userAgent.indexOf('Android') > -1) { this.system = Device.System.ANDROID; } else if (userAgent.indexOf('Windows') > -1) { this.system = Device.System.WINDOWS; } } /** * Currently only supports IOS */ protected _parseSystemVersion() { let versionRegex, System = Device.System, userAgent = this.userAgent; if (this.system === System.IOS) { versionRegex = / OS ([0-9]+\.?[0-9]*)/; // replace all _ with . userAgent = userAgent.replace(/_/g, '.'); } else if (this.system === System.WINDOWS) { versionRegex = /Windows NT ([0-9]+\.?[0-9]*)/; } if (versionRegex) { this.systemVersion = this._parseVersion(userAgent, versionRegex); } } protected _parseBrowser() { let userAgent = this.userAgent; if (userAgent.indexOf('Firefox') > -1) { this.browser = Device.Browser.FIREFOX; } else if (userAgent.indexOf('MSIE') > -1 || userAgent.indexOf('Trident') > -1) { this.browser = Device.Browser.INTERNET_EXPLORER; } else if (userAgent.indexOf('Edge') > -1) { // must check for Edge before we do other checks, because the Edge user-agent string // also contains matches for Chrome and Webkit. this.browser = Device.Browser.EDGE; } else if (userAgent.indexOf('Chrome') > -1) { this.browser = Device.Browser.CHROME; } else if (userAgent.indexOf('Safari') > -1) { this.browser = Device.Browser.SAFARI; } } /** * Version regex only matches the first number pair * but not the revision-version. Example: * - 21 match: 21 * - 21.1 match: 21.1 * - 21.1.3 match: 21.1 */ protected _parseBrowserVersion() { let versionRegex, browsers = Device.Browser, userAgent = this.userAgent; if (this.browser === browsers.INTERNET_EXPLORER) { // with Internet Explorer 11 user agent string does not contain the 'MSIE' string anymore // additionally in new version the version-number after Trident/ is not the browser-version // but the engine-version. if (userAgent.indexOf('MSIE') > -1) { versionRegex = /MSIE ([0-9]+\.?[0-9]*)/; } else { versionRegex = /rv:([0-9]+\.?[0-9]*)/; } } else if (this.browser === browsers.EDGE) { versionRegex = /Edge\/([0-9]+\.?[0-9]*)/; } else if (this.browser === browsers.SAFARI) { if (this.isIos() && userAgent.indexOf('Version/') < 0) { this.browserVersion = this.systemVersion; return; } versionRegex = /Version\/([0-9]+\.?[0-9]*)/; } else if (this.browser === browsers.FIREFOX) { versionRegex = /Firefox\/([0-9]+\.?[0-9]*)/; } else if (this.browser === browsers.CHROME) { versionRegex = /Chrome\/([0-9]+\.?[0-9]*)/; } if (versionRegex) { this.browserVersion = this._parseVersion(userAgent, versionRegex); } } protected _parseVersion(userAgent: string, versionRegex: RegExp): number { let matches = versionRegex.exec(userAgent); if (Array.isArray(matches) && matches.length === 2) { return parseFloat(matches[1]); } } supportsFeature(property: string, checkFunc: Predicate<string>): boolean { if (this.features[property] === undefined) { this.features[property] = checkFunc(property); } return this.features[property]; } /** * Currently this method should be used when you want to check if the device is "touch only" - * which means the user has no keyboard or mouse. Some hybrids like Surface tablets in desktop mode are * still touch devices, but support keyboard and mouse at the same time. In such cases this method will * return false, since the device is not touch only. * * Currently, this method returns the same as hasOnScreenKeyboard(). Maybe the implementation here will be * different in the future. */ supportsOnlyTouch(): boolean { return this.supportsFeature('_onlyTouch', this.hasOnScreenKeyboard.bind(this)); } /** * @see http://www.stucox.com/blog/you-cant-detect-a-touchscreen/ * @see https://codeburst.io/the-only-way-to-detect-touch-with-javascript-7791a3346685 */ supportsTouch(): boolean { return this.supportsFeature('_touch', property => { return (('ontouchstart' in window) || window.TouchEvent || window['DocumentTouch'] && document instanceof window['DocumentTouch']) as boolean; }); } supportsFile(): boolean { return !!window.File; } /** * Some browsers support the file API but don't support the File constructor (new File()). */ supportsFileConstructor(): boolean { return typeof File === 'function'; } supportsCssAnimation(): boolean { return this.supportsCssProperty('animation'); } /** * Used to determine if browser supports full history API. * Note that IE9 only partially supports the API, pushState and replaceState functions are missing. * @see: https://developer.mozilla.org/de/docs/Web/API/Window/history */ supportsHistoryApi(): boolean { return !!(window.history && window.history.pushState); } supportsCssGradient(): boolean { let testValue = 'linear-gradient(to left, #000 0%, #000 50%, transparent 50%, transparent 100% )'; return this.supportsFeature('gradient', this.checkCssValue.bind(this, 'backgroundImage', testValue, actualValue => { return (actualValue + '').indexOf('gradient') > 0; })); } supportsInternationalization(): boolean { return window.Intl && typeof window.Intl === 'object'; } /** * Returns true if the device supports the download of resources in the same window as the single page app is running. * With "download" we mean: change <code>window.location.href</code> to the URL of the resource to download. Some browsers don't * support this behavior and require the resource to be opened in a new window with <code>window.open</code>. */ supportsDownloadInSameWindow(): boolean { return Device.Browser.FIREFOX !== this.browser; } supportsWebcam(): boolean { return this.supportsFeature('_webcam', property => { let getUserMedia = objects.optProperty(navigator, 'mediaDevices', 'getUserMedia'); return objects.isFunction(getUserMedia); }); } supportsMicrotask(): boolean { return typeof queueMicrotask === 'function'; } supportsIntersectionObserver(): boolean { return typeof IntersectionObserver === 'function'; } hasPrettyScrollbars(): boolean { return this.supportsFeature('_prettyScrollbars', property => { return this.scrollbarWidth === 0; }); } canHideScrollbars(): boolean { return this.supportsFeature('_canHideScrollbars', property => { // Check if scrollbar is vanished if class hybrid-scrollable is applied which hides the scrollbar, see also scrollbars.js and Scrollbar.less return this._detectScrollbarWidth('hybrid-scrollable') === 0; }); } /** * If the mouse down on an element with a pseudo-element removes the pseudo-element (e.g. check box toggling), * the firefox cannot focus the element anymore and instead focuses the body. In that case manual focus handling is necessary. */ loosesFocusIfPseudoElementIsRemoved(): boolean { return Device.Browser.FIREFOX === this.browser; } supportsCssProperty(property: string): boolean { return this.supportsFeature(property, property => { if (document.body.style[property] !== undefined) { return true; } property = property.charAt(0).toUpperCase() + property.slice(1); for (let i = 0; i < Device.VENDOR_PREFIXES.length; i++) { if (document.body.style[Device.VENDOR_PREFIXES[i] + property] !== undefined) { return true; } } return false; }); } supportsGeolocation(): boolean { return !!navigator.geolocation; } /** * When we call .preventDefault() on a mousedown event Firefox doesn't apply the :active state. * Since W3C does not specify an expected behavior, we need this workaround for consistent behavior in * our UI. The issue has been reported to Mozilla, but it doesn't look like there will be a bugfix soon: * * https://bugzilla.mozilla.org/show_bug.cgi?id=771241#c7 */ requiresSyntheticActiveState(): boolean { return this.isFirefox(); } supportsPassiveEventListener(): boolean { return this.supportsFeature('_passiveEventListener', property => { // Code from MDN https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support let passiveSupported = false; try { let options = Object.defineProperty({}, 'passive', { get: () => { passiveSupported = true; return false; } }); // @ts-expect-error window.addEventListener('test', options, options); // @ts-expect-error window.removeEventListener('test', options, options); } catch (err) { passiveSupported = false; } return passiveSupported; }); } checkCssValue(property: string, value: string, checkFunc: Predicate<string>): boolean { // Check if property is supported at all, otherwise div.style[property] would just add it and checkFunc would always return true if (document.body.style[property] === undefined) { return false; } let div = document.createElement('div'); div.style[property] = value; if (checkFunc(div.style[property])) { return true; } property = property.charAt(0).toUpperCase() + property.slice(1); for (let i = 0; i < Device.VENDOR_PREFIXES.length; i++) { let vendorProperty = Device.VENDOR_PREFIXES[i] + property; if (document.body.style[vendorProperty] !== undefined) { div.style[vendorProperty] = value; if (checkFunc(div.style[vendorProperty])) { return true; } } } return false; } /** * https://bugs.chromium.org/p/chromium/issues/detail?id=740502 */ hasTableCellZoomBug(): boolean { return this.browser === Device.Browser.CHROME; } protected _detectScrollbarWidth(cssClass?: string): number { let $measure = $('body') .appendDiv(cssClass) .attr('id', 'MeasureScrollbar') .css('width', 50) .css('height', 50) .css('overflow-y', 'scroll'), measureElement = $measure[0]; let scrollbarWidth = measureElement.offsetWidth - measureElement.clientWidth; $measure.remove(); return scrollbarWidth; } toString(): string { return 'scout.Device[' + 'system=' + this.system + ' browser=' + this.browser + ' browserVersion=' + this.browserVersion + ' type=' + this.type + ' scrollbarWidth=' + this.scrollbarWidth + ' features=' + JSON.stringify(this.features) + ']'; } static get(): Device { return instance; } } App.addListener('prepare', () => { if (instance) { // if the device was created before the app itself, use it instead of creating a new one return; } instance = scout.create(Device, { userAgent: navigator.userAgent }); });