export type StylesPanelMode = "compact" | "full" | "mobile"; export type EditorInterface = Readonly<{ formFactor: "phone" | "tablet" | "desktop"; desktopUIMode: "compact" | "full"; userAgent: Readonly<{ isMobileDevice: boolean; platform: "ios" | "android" | "other" | "unknown"; }>; isTouchScreen: boolean; canFitSidebar: boolean; isLandscape: boolean; }>; // storage key const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode"; // breakpoints export const MQ_MAX_MOBILE = 599; export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_HEIGHT_LANDSCAPE = 500; // tablets export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MAX_TABLET = 1180; // ipad air // desktop/laptop (NOTE: not used for form factor detection) export const MQ_MIN_WIDTH_DESKTOP = 1440; // sidebar export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; // ----------------------------------------------------------------------------- // user agent detections export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); export const isFirefox = typeof window !== "undefined" && "netscape" in window && navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("Gecko") > 1; export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; export const isSafari = !isChrome && navigator.userAgent.indexOf("Safari") !== -1; export const isIOS = /iPad|iPhone/i.test(navigator.platform) || // iPadOS 13+ (navigator.userAgent.includes("Mac") && "ontouchend" in document); // keeping function so it can be mocked in test export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; // export const isMobile = // isIOS || // /android|webos|ipod|blackberry|iemobile|opera mini/i.test( // navigator.userAgent, // ) || // /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform); // utilities export const isMobileBreakpoint = (width: number, height: number) => { return ( width <= MQ_MAX_MOBILE || (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) ); }; export const isTabletBreakpoint = ( editorWidth: number, editorHeight: number, ) => { const minSide = Math.min(editorWidth, editorHeight); const maxSide = Math.max(editorWidth, editorHeight); return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET; }; const isMobileOrTablet = (): boolean => { const ua = navigator.userAgent || ""; const platform = navigator.platform || ""; const uaData = (navigator as any).userAgentData as | { mobile?: boolean; platform?: string } | undefined; // --- 1) chromium: prefer ua client hints ------------------------------- if (uaData) { const plat = (uaData.platform || "").toLowerCase(); const isDesktopOS = plat === "windows" || plat === "macos" || plat === "linux" || plat === "chrome os"; if (uaData.mobile === true) { return true; } if (uaData.mobile === false && plat === "android") { const looksTouchTablet = matchMedia?.("(hover: none)").matches && matchMedia?.("(pointer: coarse)").matches; return looksTouchTablet; } if (isDesktopOS) { return false; } } // --- 2) ios (includes ipad) -------------------------------------------- if (isIOS) { return true; } // --- 3) android legacy ua fallback ------------------------------------- if (isAndroid) { const isAndroidPhone = /Mobile/i.test(ua); const isAndroidTablet = !isAndroidPhone; if (isAndroidPhone || isAndroidTablet) { const looksTouchTablet = matchMedia?.("(hover: none)").matches && matchMedia?.("(pointer: coarse)").matches; return looksTouchTablet; } } // --- 4) last resort desktop exclusion ---------------------------------- const looksDesktopPlatform = /Win|Linux|CrOS|Mac/.test(platform) || /Windows NT|X11|CrOS|Macintosh/.test(ua); if (looksDesktopPlatform) { return false; } return false; }; export const getFormFactor = ( editorWidth: number, editorHeight: number, ): EditorInterface["formFactor"] => { if (isMobileBreakpoint(editorWidth, editorHeight)) { return "phone"; } if (isTabletBreakpoint(editorWidth, editorHeight)) { return "tablet"; } return "desktop"; }; export const deriveStylesPanelMode = ( editorInterface: EditorInterface, ): StylesPanelMode => { if (editorInterface.formFactor === "phone") { return "mobile"; } if (editorInterface.formFactor === "tablet") { return "compact"; } return editorInterface.desktopUIMode; }; export const createUserAgentDescriptor = ( userAgentString: string, ): EditorInterface["userAgent"] => { const normalizedUA = userAgentString ?? ""; let platform: EditorInterface["userAgent"]["platform"] = "unknown"; if (isIOS) { platform = "ios"; } else if (isAndroid) { platform = "android"; } else if (normalizedUA) { platform = "other"; } return { isMobileDevice: isMobileOrTablet(), platform, } as const; }; export const loadDesktopUIModePreference = () => { if (typeof window === "undefined") { return null; } try { const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY); if (stored === "compact" || stored === "full") { return stored as EditorInterface["desktopUIMode"]; } } catch (error) { // ignore storage access issues (e.g., Safari private mode) } return null; }; const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => { if (typeof window === "undefined") { return; } try { window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode); } catch (error) { // ignore storage access issues (e.g., Safari private mode) } }; export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => { if (mode !== "compact" && mode !== "full") { return; } persistDesktopUIMode(mode); return mode; };