diff --git a/packages/roosterjs-editor-dom/lib/utils/Browser.ts b/packages/roosterjs-editor-dom/lib/utils/Browser.ts index f4b5dead74b..fa36d8058f6 100644 --- a/packages/roosterjs-editor-dom/lib/utils/Browser.ts +++ b/packages/roosterjs-editor-dom/lib/utils/Browser.ts @@ -6,9 +6,10 @@ const isAndroidRegex = /android/i; * Get current browser information from user agent string * @param userAgent The userAgent string of a browser * @param appVersion The appVersion string of a browser + * @param vendor The vendor string of a browser * @returns The BrowserInfo object calculated from the given userAgent and appVersion */ -export function getBrowserInfo(userAgent: string, appVersion: string): BrowserInfo { +export function getBrowserInfo(userAgent: string, appVersion: string, vendor?: string): BrowserInfo { // checks whether the browser is running in IE // IE11 will use rv in UA instead of MSIE. Unfortunately Firefox also uses this. We should also look for "Trident" to confirm this. // There have been cases where companies using older version of IE and custom UserAgents have broken this logic (e.g. IE 10 and KellyServices) @@ -22,6 +23,19 @@ export function getBrowserInfo(userAgent: string, appVersion: string): BrowserIn let isSafari = false; let isEdge = false; let isWebKit = userAgent.indexOf('WebKit') != -1; + let isMobileOrTablet = false; + + // Reference: http://detectmobilebrowsers.com/ + // The default regex on the website doesn't consider tablet. + // To support tablet, add |android|ipad|playbook|silk to the first regex according to the info in /about page + ((userAgentOrVendor: string) => { + if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(userAgentOrVendor) + || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(userAgentOrVendor.substr(0, 4)) + ) { + isMobileOrTablet = true; + } + })(userAgent || vendor || ""); + if (!isIE) { isChrome = userAgent.indexOf('Chrome') != -1; @@ -56,6 +70,7 @@ export function getBrowserInfo(userAgent: string, appVersion: string): BrowserIn isEdge, isIEOrEdge: isIE || isEdge, isAndroid, + isMobileOrTablet, }; } @@ -63,5 +78,5 @@ export function getBrowserInfo(userAgent: string, appVersion: string): BrowserIn * Browser object contains browser and operating system information of current environment */ export const Browser = window - ? getBrowserInfo(window.navigator.userAgent, window.navigator.appVersion) + ? getBrowserInfo(window.navigator.userAgent, window.navigator.appVersion, window.navigator.vendor) : {}; diff --git a/packages/roosterjs-editor-dom/test/utils/BrowserTest.ts b/packages/roosterjs-editor-dom/test/utils/BrowserTest.ts index 1c34c65e026..e61467d9050 100644 --- a/packages/roosterjs-editor-dom/test/utils/BrowserTest.ts +++ b/packages/roosterjs-editor-dom/test/utils/BrowserTest.ts @@ -2,7 +2,7 @@ import { BrowserInfo } from 'roosterjs-editor-types'; import { getBrowserInfo } from '../../lib/utils/Browser'; function runBrowserDataTest(userAgent: string, appVersion: string, expected: BrowserInfo): void { - let b = getBrowserInfo(userAgent, appVersion); + let b = getBrowserInfo(userAgent, appVersion, ''); expect(b.isChrome).toBe(expected.isChrome); expect(b.isEdge).toBe(expected.isEdge); expect(b.isFirefox).toBe(expected.isFirefox); @@ -28,6 +28,7 @@ describe('getBrowserData', () => { isSafari: true, isWebKit: true, isWin: false, + isMobileOrTablet: false, } ); }); @@ -46,6 +47,7 @@ describe('getBrowserData', () => { isSafari: false, isWebKit: false, isWin: true, + isMobileOrTablet: false, } ); }); @@ -64,6 +66,7 @@ describe('getBrowserData', () => { isSafari: false, isWebKit: true, isWin: true, + isMobileOrTablet: false, } ); }); @@ -82,6 +85,7 @@ describe('getBrowserData', () => { isSafari: false, isWebKit: false, isWin: true, + isMobileOrTablet: false, } ); }); @@ -100,6 +104,7 @@ describe('getBrowserData', () => { isSafari: false, isWebKit: false, isWin: true, + isMobileOrTablet: false, } ); }); @@ -118,6 +123,7 @@ describe('getBrowserData', () => { isSafari: false, isWebKit: false, isWin: true, + isMobileOrTablet: false, } ); }); diff --git a/packages/roosterjs-editor-plugins/lib/pluginUtils/DragAndDropHelper.ts b/packages/roosterjs-editor-plugins/lib/pluginUtils/DragAndDropHelper.ts index 92e717e8ca8..bd9c301ecb2 100644 --- a/packages/roosterjs-editor-plugins/lib/pluginUtils/DragAndDropHelper.ts +++ b/packages/roosterjs-editor-plugins/lib/pluginUtils/DragAndDropHelper.ts @@ -1,6 +1,37 @@ +import { Browser } from 'roosterjs-editor-dom/lib'; import Disposable from './Disposable'; import DragAndDropHandler from './DragAndDropHandler'; +/** + * @internal + * Compatible mouse event names for different platform + */ +interface MouseEventNames { + MOUSEDOWN: string; + MOUSEMOVE: string; + MOUSEUP: string; +} + +/** + * Generate event names based on different platforms to be compatible with desktop and mobile browsers + */ +const MOUSE_EVENT_NAMES: MouseEventNames = (() => { + if (Browser.isMobileOrTablet) { + return { + MOUSEDOWN: 'touchstart', + MOUSEMOVE: 'touchmove', + MOUSEUP: 'touchend', + } + } else { + return { + MOUSEDOWN: 'mousedown', + MOUSEMOVE: 'mousemove', + MOUSEUP: 'mouseup', + } + } +})() + + /** * @internal * A helper class to help manage drag and drop to an HTML element @@ -26,27 +57,27 @@ export default class DragAndDropHelper implements Disposab private handler: DragAndDropHandler, private zoomScale: number ) { - trigger.addEventListener('mousedown', this.onMouseDown); + trigger.addEventListener(MOUSE_EVENT_NAMES.MOUSEDOWN, this.onMouseDown); } /** * Dispose this object, remove all event listeners that has been attached */ dispose() { - this.trigger.removeEventListener('mousedown', this.onMouseDown); + this.trigger.removeEventListener(MOUSE_EVENT_NAMES.MOUSEDOWN, this.onMouseDown); this.removeDocumentEvents(); } private addDocumentEvents() { const doc = this.trigger.ownerDocument; - doc.addEventListener('mousemove', this.onMouseMove, true /*useCapture*/); - doc.addEventListener('mouseup', this.onMouseUp, true /*useCapture*/); + doc.addEventListener(MOUSE_EVENT_NAMES.MOUSEMOVE, this.onMouseMove, true /*useCapture*/); + doc.addEventListener(MOUSE_EVENT_NAMES.MOUSEUP, this.onMouseUp, true /*useCapture*/); } private removeDocumentEvents() { const doc = this.trigger.ownerDocument; - doc.removeEventListener('mousemove', this.onMouseMove, true /*useCapture*/); - doc.removeEventListener('mouseup', this.onMouseUp, true /*useCapture*/); + doc.removeEventListener(MOUSE_EVENT_NAMES.MOUSEMOVE, this.onMouseMove, true /*useCapture*/); + doc.removeEventListener(MOUSE_EVENT_NAMES.MOUSEUP, this.onMouseUp, true /*useCapture*/); } private onMouseDown = (e: MouseEvent) => { diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index 8cce0bed2fb..add05251f8c 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -1,6 +1,6 @@ import applyChange from './editInfoUtils/applyChange'; import canRegenerateImage from './api/canRegenerateImage'; -import DragAndDropContext, { X, Y } from './types/DragAndDropContext'; +import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from './types/DragAndDropContext'; import DragAndDropHandler from '../../pluginUtils/DragAndDropHandler'; import DragAndDropHelper from '../../pluginUtils/DragAndDropHelper'; import getGeneratedImageSize from './editInfoUtils/getGeneratedImageSize'; @@ -29,6 +29,7 @@ import { getSideResizeHTML, getCornerResizeHTML, getResizeBordersHTML, + OnShowResizeHandle, } from './imageEditors/Resizer'; import { ExperimentalFeatures, @@ -138,8 +139,12 @@ export default class ImageEdit implements EditorPlugin { /** * Create a new instance of ImageEdit * @param options Image editing options + * @param onShowResizeHandle An optional callback to allow customize resize handle element of image resizing. + * To customize the resize handle element, add this callback and change the attributes of elementData then it + * will be picked up by ImageEdit code */ - constructor(options?: ImageEditOptions) { + constructor(options?: ImageEditOptions, + private onShowResizeHandle?: OnShowResizeHandle) { this.options = { ...DefaultOptions, ...(options || {}), @@ -390,7 +395,7 @@ export default class ImageEdit implements EditorPlugin { ((Object.keys(ImageEditHTMLMap) as any[]) as (keyof typeof ImageEditHTMLMap)[]).forEach( thisOperation => { if ((operation & thisOperation) == thisOperation) { - arrayPush(htmlData, ImageEditHTMLMap[thisOperation](options)); + arrayPush(htmlData, ImageEditHTMLMap[thisOperation](options, this.onShowResizeHandle)); } } ); @@ -560,8 +565,8 @@ export default class ImageEdit implements EditorPlugin { element, { ...commonContext, - x: element.dataset.x as X, - y: element.dataset.y as Y, + x: element.dataset.x as DNDDirectionX, + y: element.dataset.y as DnDDirectionY, }, this.updateWrapper, dragAndDrop, diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts index e4fc25c88ae..0c62292b010 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts @@ -1,4 +1,4 @@ -import DragAndDropContext, { X, Y } from '../types/DragAndDropContext'; +import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; import DragAndDropHandler from '../../../pluginUtils/DragAndDropHandler'; import { CreateElementData } from 'roosterjs-editor-types'; import { CropInfo } from '../types/ImageEditInfo'; @@ -7,8 +7,8 @@ import { rotateCoordinate } from './Resizer'; const CROP_HANDLE_SIZE = 22; const CROP_HANDLE_WIDTH = 7; -const Xs: X[] = ['w', 'e']; -const Ys: Y[] = ['s', 'n']; +const Xs: DNDDirectionX[] = ['w', 'e']; +const Ys: DnDDirectionY[] = ['s', 'n']; const ROTATION: Record = { sw: 0, nw: 90, @@ -106,7 +106,7 @@ export function getCropHTML(): CreateElementData[] { return [containerHTML, overlayHTML, overlayHTML, overlayHTML, overlayHTML]; } -function getCropHTMLInternal(x: X, y: Y): CreateElementData { +function getCropHTMLInternal(x: DNDDirectionX, y: DnDDirectionY): CreateElementData { const leftOrRight = x == 'w' ? 'left' : 'right'; const topOrBottom = y == 'n' ? 'top' : 'bottom'; const rotation = ROTATION[y + x]; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Resizer.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Resizer.ts index e1f7c535594..bcf4dbdd661 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Resizer.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Resizer.ts @@ -1,18 +1,31 @@ -import DragAndDropContext, { X, Y } from '../types/DragAndDropContext'; +import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; import DragAndDropHandler from '../../../pluginUtils/DragAndDropHandler'; import ImageEditInfo, { ResizeInfo } from '../types/ImageEditInfo'; import ImageHtmlOptions from '../types/ImageHtmlOptions'; import { CreateElementData } from 'roosterjs-editor-types'; import { ImageEditElementClass } from '../types/ImageEditElementClass'; +/** + * An optional callback to allow customize resize handle element of image resizing. + * To customize the resize handle element, add this callback and change the attributes of elementData then it + * will be picked up by ImageEdit code + */ +export interface OnShowResizeHandle { + ( + elementData: CreateElementData, + x: DNDDirectionX, + y: DnDDirectionY + ): void +} + const enum HandleTypes { SquareHandles, CircularHandlesCorner, } const RESIZE_HANDLE_SIZE = 10; const RESIZE_HANDLE_MARGIN = 3; -const Xs: X[] = ['w', '', 'e']; -const Ys: Y[] = ['s', '', 'n']; +const Xs: DNDDirectionX[] = ['w', '', 'e']; +const Ys: DnDDirectionY[] = ['s', '', 'n']; /** * @internal @@ -111,27 +124,32 @@ export function doubleCheckResize( * @internal * Get HTML for resize handles at the corners */ -export function getCornerResizeHTML({ - borderColor: resizeBorderColor, - handlesExperimentalFeatures: handlesExperimentalFeatures, -}: ImageHtmlOptions): CreateElementData[] { +export function getCornerResizeHTML( + { + borderColor: resizeBorderColor, + handlesExperimentalFeatures: handlesExperimentalFeatures, + }: ImageHtmlOptions, + onShowResizeHandle?: OnShowResizeHandle +): CreateElementData[] { const result: CreateElementData[] = []; Xs.forEach(x => - Ys.forEach(y => - result.push( - (x == '') == (y == '') - ? getResizeHandleHTML( - x, - y, - resizeBorderColor, - handlesExperimentalFeatures - ? HandleTypes.CircularHandlesCorner - : HandleTypes.SquareHandles - ) - : null - ) - ) + Ys.forEach(y => { + let elementData = (x == '') == (y == '') + ? getResizeHandleHTML( + x, + y, + resizeBorderColor, + handlesExperimentalFeatures + ? HandleTypes.CircularHandlesCorner + : HandleTypes.SquareHandles + ) + : null; + if (onShowResizeHandle) { + onShowResizeHandle(elementData, x, y) + } + result.push(elementData); + }) ); return result; } @@ -140,31 +158,35 @@ export function getCornerResizeHTML({ * @internal * Get HTML for resize handles on the sides */ -export function getSideResizeHTML({ - borderColor: resizeBorderColor, - isSmallImage: isSmallImage, - handlesExperimentalFeatures: handlesExperimentalFeatures, -}: ImageHtmlOptions): CreateElementData[] { +export function getSideResizeHTML( + { + borderColor: resizeBorderColor, + isSmallImage: isSmallImage, + handlesExperimentalFeatures: handlesExperimentalFeatures, + }: ImageHtmlOptions, + onShowResizeHandle?: OnShowResizeHandle +): CreateElementData[] { if (isSmallImage) { return null; } - const result: CreateElementData[] = []; Xs.forEach(x => - Ys.forEach(y => - result.push( - (x == '') != (y == '') - ? getResizeHandleHTML( - x, - y, - resizeBorderColor, - handlesExperimentalFeatures - ? HandleTypes.CircularHandlesCorner - : HandleTypes.SquareHandles - ) - : null - ) - ) + Ys.forEach(y => { + let elementData = (x == '') != (y == '') + ? getResizeHandleHTML( + x, + y, + resizeBorderColor, + handlesExperimentalFeatures + ? HandleTypes.CircularHandlesCorner + : HandleTypes.SquareHandles + ) + : null; + if (onShowResizeHandle) { + onShowResizeHandle(elementData, x, y); + } + result.push(elementData); + }) ); return result; } @@ -183,8 +205,8 @@ export function getResizeBordersHTML({ } function getResizeHandleHTML( - x: X, - y: Y, + x: DNDDirectionX, + y: DnDDirectionY, borderColor: string, handleTypes: HandleTypes ): CreateElementData { diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/index.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/index.ts index 9f97e556ddb..614981d0f65 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/index.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/index.ts @@ -3,3 +3,5 @@ export { default as canRegenerateImage } from './api/canRegenerateImage'; export { default as resizeByPercentage } from './api/resizeByPercentage'; export { default as isResizedTo } from './api/isResizedTo'; export { default as resetImage } from './api/resetImage'; +export { OnShowResizeHandle } from './imageEditors/Resizer'; +export { DNDDirectionX, DnDDirectionY } from './types/DragAndDropContext'; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/types/DragAndDropContext.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/types/DragAndDropContext.ts index d52f1c2dc31..607a526ed5c 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/types/DragAndDropContext.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/types/DragAndDropContext.ts @@ -3,16 +3,14 @@ import { ImageEditElementClass } from './ImageEditElementClass'; import { ImageEditOptions } from 'roosterjs-editor-types'; /** - * @internal * Horizontal direction types for image edit */ -export type X = 'w' | '' | 'e'; +export type DNDDirectionX = 'w' | '' | 'e'; /** - * @internal * Vertical direction types for image edit */ -export type Y = 'n' | '' | 's'; +export type DnDDirectionY = 'n' | '' | 's'; /** * @internal @@ -32,12 +30,12 @@ export default interface DragAndDropContext { /** * Horizontal direction */ - x: X; + x: DNDDirectionX; /** * Vertical direction */ - y: Y; + y: DnDDirectionY; /** * Edit options diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/ResizerTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/ResizerTest.ts index 508d118c9f7..297bf430b72 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/ResizerTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/ResizerTest.ts @@ -1,4 +1,4 @@ -import DragAndDropContext, { X, Y } from '../../lib/plugins/ImageEdit/types/DragAndDropContext'; +import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from '../../lib/plugins/ImageEdit/types/DragAndDropContext'; import ImageEditInfo, { ResizeInfo } from '../../lib/plugins/ImageEdit/types/ImageEditInfo'; import { ImageEditOptions } from 'roosterjs-editor-types'; import { Resizer } from '../../lib/plugins/ImageEdit/imageEditors/Resizer'; @@ -12,8 +12,8 @@ describe('Resizer: resize only', () => { const initValue: ResizeInfo = { widthPx: 100, heightPx: 200 }; const mouseEvent: MouseEvent = {} as any; const mouseEventShift: MouseEvent = { shiftKey: true } as any; - const Xs: X[] = ['w', '', 'e']; - const Ys: Y[] = ['n', '', 's']; + const Xs: DNDDirectionX[] = ['w', '', 'e']; + const Ys: DnDDirectionY[] = ['n', '', 's']; function getInitEditInfo(): ImageEditInfo { return { @@ -33,7 +33,7 @@ describe('Resizer: resize only', () => { function runTest( e: MouseEvent, getEditInfo: () => ImageEditInfo, - expectedResult: Record> + expectedResult: Record> ) { const actualResult: { [key: string]: { [key: string]: [number, number] } } = {}; Xs.forEach(x => { diff --git a/packages/roosterjs-editor-types/lib/browser/BrowserInfo.ts b/packages/roosterjs-editor-types/lib/browser/BrowserInfo.ts index 0ed3e54f74e..ac9840b20f6 100644 --- a/packages/roosterjs-editor-types/lib/browser/BrowserInfo.ts +++ b/packages/roosterjs-editor-types/lib/browser/BrowserInfo.ts @@ -56,4 +56,9 @@ export default interface BrowserInfo { * Whether current OS is Android */ readonly isAndroid?: boolean; + + /** + * Whether current browser is on mobile or a tablet + */ + readonly isMobileOrTablet?: boolean; }