From fffd105dc978eedb21a1502cd32b936a7ef5fdcd Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 20 Oct 2025 10:06:25 +1100 Subject: [PATCH] refactor: move related apis into one file --- excalidraw-app/App.tsx | 4 + packages/common/src/constants.ts | 26 --- packages/common/src/editorInterface.ts | 152 ++++++++++++++++++ packages/common/src/index.ts | 1 + packages/common/src/keys.ts | 2 +- packages/common/src/utils.ts | 61 +------ packages/element/src/resizeTest.ts | 11 +- packages/element/src/transformHandles.ts | 6 +- .../excalidraw/actions/actionProperties.tsx | 4 +- packages/excalidraw/components/App.tsx | 38 ++--- packages/excalidraw/components/HintViewer.tsx | 4 +- .../components/canvases/InteractiveCanvas.tsx | 7 +- .../LiveCollaborationTrigger.tsx | 7 +- packages/excalidraw/editorInterface.ts | 59 ------- packages/excalidraw/scene/types.ts | 3 +- packages/excalidraw/types.ts | 16 +- 16 files changed, 202 insertions(+), 199 deletions(-) create mode 100644 packages/common/src/editorInterface.ts delete mode 100644 packages/excalidraw/editorInterface.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index a5d01769cc..4e46bb4d06 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -4,6 +4,7 @@ import { TTDDialogTrigger, CaptureUpdateAction, reconcileElements, + useEditorInterface, } from "@excalidraw/excalidraw"; import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; @@ -342,6 +343,8 @@ const ExcalidrawWrapper = () => { const [langCode, setLangCode] = useAppLangCode(); + const editorInterface = useEditorInterface(); + // initial state // --------------------------------------------------------------------------- @@ -856,6 +859,7 @@ const ExcalidrawWrapper = () => { onSelect={() => setShareDialogState({ isOpen: true, type: "share" }) } + editorInterface={editorInterface} /> ); diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index dfbb69aa97..028bfe9d83 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -6,32 +6,6 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types"; import { COLOR_PALETTE } from "./colors"; -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); - export const supportsResizeObserver = typeof window !== "undefined" && "ResizeObserver" in window; diff --git a/packages/common/src/editorInterface.ts b/packages/common/src/editorInterface.ts new file mode 100644 index 0000000000..c0d3cd31b7 --- /dev/null +++ b/packages/common/src/editorInterface.ts @@ -0,0 +1,152 @@ +export type StylesPanelMode = "compact" | "full" | "mobile"; + +export type EditorInterface = Readonly<{ + formFactor: "phone" | "tablet" | "desktop"; + desktopUIMode: "compact" | "full"; + userAgent: Readonly<{ + raw: string; + isMobileDevice: boolean; + platform: "ios" | "android" | "other" | "unknown"; + }>; + isTouchScreen: boolean; + canFitSidebar: boolean; + isLandscape: boolean; +}>; + +export const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode"; + +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); + +export 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 deriveFormFactor = ( + editorWidth: number, + editorHeight: number, + breakpoints: { + isMobile: (width: number, height: number) => boolean; + isTablet: (width: number, height: number) => boolean; + }, +): EditorInterface["formFactor"] => { + if (breakpoints.isMobile(editorWidth, editorHeight)) { + return "phone"; + } + + if (breakpoints.isTablet(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 { + raw: normalizedUA, + isMobileDevice: isMobileOrTablet(), + platform, + } as const; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 79f243f4f0..b4213df455 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -10,3 +10,4 @@ export * from "./random"; export * from "./url"; export * from "./utils"; export * from "./emitter"; +export * from "./editorInterface"; diff --git a/packages/common/src/keys.ts b/packages/common/src/keys.ts index 948e7f568f..1db356b7ec 100644 --- a/packages/common/src/keys.ts +++ b/packages/common/src/keys.ts @@ -1,4 +1,4 @@ -import { isDarwin } from "./constants"; +import { isDarwin } from "./editorInterface"; import type { ValueOf } from "./utility-types"; diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index c65efaacf9..313ae4e0eb 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -20,12 +20,11 @@ import { ENV, FONT_FAMILY, getFontFamilyFallbacks, - isDarwin, - isAndroid, - isIOS, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; +import { isDarwin } from "./editorInterface"; + import type { MaybePromise, ResolutionType } from "./utility-types"; import type { EVENT } from "./constants"; @@ -1286,59 +1285,3 @@ export const reduceToCommonValue = ( return commonValue; }; - -export 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; -}; diff --git a/packages/element/src/resizeTest.ts b/packages/element/src/resizeTest.ts index 84b5c2009b..4257d4a7e8 100644 --- a/packages/element/src/resizeTest.ts +++ b/packages/element/src/resizeTest.ts @@ -5,15 +5,14 @@ import { type Radians, } from "@excalidraw/math"; -import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common"; +import { + SIDE_RESIZING_THRESHOLD, + type EditorInterface, +} from "@excalidraw/common"; import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math"; -import type { - AppState, - EditorInterface, - Zoom, -} from "@excalidraw/excalidraw/types"; +import type { AppState, Zoom } from "@excalidraw/excalidraw/types"; import { getElementAbsoluteCoords } from "./bounds"; import { diff --git a/packages/element/src/transformHandles.ts b/packages/element/src/transformHandles.ts index d06c98cf95..d562716948 100644 --- a/packages/element/src/transformHandles.ts +++ b/packages/element/src/transformHandles.ts @@ -1,11 +1,13 @@ -import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common"; +import { + DEFAULT_TRANSFORM_HANDLE_SPACING, + type EditorInterface, +} from "@excalidraw/common"; import { pointFrom, pointRotateRads } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math"; import type { - EditorInterface, InteractiveCanvasAppState, Zoom, } from "@excalidraw/excalidraw/types"; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 7b3b161e0d..662ab7ae61 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -58,6 +58,8 @@ import { toggleLinePolygonState, } from "@excalidraw/element"; +import { deriveStylesPanelMode } from "@excalidraw/common"; + import type { LocalPoint } from "@excalidraw/math"; import type { @@ -139,8 +141,6 @@ import { restoreCaretPosition, } from "../hooks/useTextEditorFocus"; -import { deriveStylesPanelMode } from "../editorInterface"; - import { register } from "./register"; import type { AppClassProperties, AppState, Primitive } from "../types"; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9ac6ef7f4e..ddcf872d99 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -37,7 +37,6 @@ import { FRAME_STYLE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, - isBrave, LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, MIME_TYPES, @@ -55,13 +54,11 @@ import { ZOOM_STEP, POINTER_EVENTS, TOOL_TYPE, - isIOS, supportsResizeObserver, DEFAULT_COLLISION_THRESHOLD, DEFAULT_TEXT_ALIGN, ARROW_TYPE, DEFAULT_REDUCED_GLOBAL_ALPHA, - isSafari, isLocalLink, normalizeLink, toValidURL, @@ -99,12 +96,20 @@ import { Emitter, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, - isMobileOrTablet, MQ_MAX_MOBILE, MQ_MIN_TABLET, MQ_MAX_TABLET, MQ_MAX_HEIGHT_LANDSCAPE, MQ_MAX_WIDTH_LANDSCAPE, + DESKTOP_UI_MODE_STORAGE_KEY, + createUserAgentDescriptor, + deriveFormFactor, + deriveStylesPanelMode, + isIOS, + isBrave, + isSafari, + type EditorInterface, + type StylesPanelMode, } from "@excalidraw/common"; import { @@ -406,13 +411,6 @@ import { LassoTrail } from "../lasso"; import { EraserTrail } from "../eraser"; -import { - DESKTOP_UI_MODE_STORAGE_KEY, - createUserAgentDescriptor, - deriveFormFactor, - deriveStylesPanelMode, -} from "../editorInterface"; - import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -466,8 +464,6 @@ import type { LibraryItems, PointerDownState, SceneData, - EditorInterface, - StylesPanelMode, FrameNameBoundsCache, SidebarName, SidebarTabName, @@ -3236,7 +3232,8 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files: data.files || null, - position: isMobileOrTablet() ? "center" : "cursor", + position: + this.editorInterface.formFactor === "desktop" ? "cursor" : "center", retainSeed: isPlainPaste, }); return; @@ -3261,7 +3258,8 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files, - position: isMobileOrTablet() ? "center" : "cursor", + position: + this.editorInterface.formFactor === "desktop" ? "cursor" : "center", }); return; @@ -6783,12 +6781,13 @@ class App extends React.Component { // block dragging after lasso selection on PCs until the next pointer down // (on mobile or tablet, we want to allow user to drag immediately) - pointerDownState.drag.blockDragging = !isMobileOrTablet(); + pointerDownState.drag.blockDragging = + this.editorInterface.formFactor === "desktop"; } // only for mobile or tablet, if we hit an element, select it immediately like normal selection if ( - isMobileOrTablet() && + this.editorInterface.formFactor !== "desktop" && pointerDownState.hit.element && !hitSelectedElement ) { @@ -8599,7 +8598,10 @@ class App extends React.Component { if ( this.state.activeTool.type === "lasso" && this.lassoTrail.hasCurrentTrail && - !(isMobileOrTablet() && pointerDownState.hit.element) && + !( + this.editorInterface.formFactor !== "desktop" && + pointerDownState.hit.element + ) && !this.state.activeTool.fromSelection ) { return; diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 3f5f5d6c40..50d0a10bd9 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -9,7 +9,7 @@ import { isTextElement, } from "@excalidraw/element"; -import { getShortcutKey } from "@excalidraw/common"; +import { getShortcutKey, type EditorInterface } from "@excalidraw/common"; import { isNodeInFlowchart } from "@excalidraw/element"; @@ -19,7 +19,7 @@ import { isGridModeEnabled } from "../snapping"; import "./HintViewer.scss"; -import type { AppClassProperties, EditorInterface, UIAppState } from "../types"; +import type { AppClassProperties, UIAppState } from "../types"; interface HintViewerProps { appState: UIAppState; diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 1f7a37a454..1bbf3789c6 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -4,6 +4,7 @@ import { CURSOR_TYPE, isShallowEqual, sceneCoordsToViewportCoords, + type EditorInterface, } from "@excalidraw/common"; import type { @@ -20,11 +21,7 @@ import type { RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; -import type { - AppState, - EditorInterface, - InteractiveCanvasAppState, -} from "../../types"; +import type { AppState, InteractiveCanvasAppState } from "../../types"; import type { DOMAttributes } from "react"; type InteractiveCanvasProps = { diff --git a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx index 0efadcfc62..3a611be04b 100644 --- a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx +++ b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; -import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common"; +import { MQ_MIN_WIDTH_DESKTOP, type EditorInterface } from "@excalidraw/common"; import { t } from "../../i18n"; import { Button } from "../Button"; @@ -12,15 +12,18 @@ import "./LiveCollaborationTrigger.scss"; const LiveCollaborationTrigger = ({ isCollaborating, onSelect, + editorInterface, ...rest }: { isCollaborating: boolean; onSelect: () => void; + editorInterface?: EditorInterface; } & React.ButtonHTMLAttributes) => { const appState = useUIAppState(); const showIconOnly = - isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP; + editorInterface?.formFactor !== "desktop" || + appState.width < MQ_MIN_WIDTH_DESKTOP; return (