diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx index e7852cee94..39e8e18425 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx @@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co ```jsx live function App() { return ( -
+
); diff --git a/excalidraw-app/tests/MobileMenu.test.tsx b/excalidraw-app/tests/MobileMenu.test.tsx index 400b625ec2..70f2162f9b 100644 --- a/excalidraw-app/tests/MobileMenu.test.tsx +++ b/excalidraw-app/tests/MobileMenu.test.tsx @@ -17,30 +17,15 @@ describe("Test MobileMenu", () => { beforeEach(async () => { await render(); - // @ts-ignore - h.app.refreshViewportBreakpoints(); - // @ts-ignore - h.app.refreshEditorBreakpoints(); + h.app.refreshEditorInterface(); }); afterAll(() => { restoreOriginalGetBoundingClientRect(); }); - it("should set device correctly", () => { - expect(h.app.device).toMatchInlineSnapshot(` - { - "editor": { - "canFitSidebar": false, - "isMobile": true, - }, - "isTouchScreen": false, - "viewport": { - "isLandscape": true, - "isMobile": true, - }, - } - `); + it("should set editor interface correctly", () => { + expect(h.app.editorInterface.formFactor).toBe("phone"); }); it("should initialize with welcome screen and hide once user interacts", async () => { diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index a377007fed..191bec5ac6 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; @@ -349,26 +323,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { }, }; -// breakpoints -// ----------------------------------------------------------------------------- - -// mobile: up to 699px -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 = 1400; // upper bound (excludes laptops/desktops) - -// desktop/laptop -export const MQ_MIN_WIDTH_DESKTOP = 1440; - -// sidebar -export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; -// ----------------------------------------------------------------------------- - export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; export const EXPORT_SCALES = [1, 2, 3]; diff --git a/packages/common/src/editorInterface.ts b/packages/common/src/editorInterface.ts new file mode 100644 index 0000000000..36b55d9182 --- /dev/null +++ b/packages/common/src/editorInterface.ts @@ -0,0 +1,223 @@ +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 +// mobile: up to 699px +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 = 1400; // upper bound (excludes laptops/desktops) + +// desktop/laptop +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; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 9e28ce4132..cb85d0435f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,3 +11,4 @@ export * from "./url"; export * from "./utils"; export * from "./emitter"; export * from "./visualdebug"; +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 4803e0b58d..f2bb2c47c6 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -16,8 +16,6 @@ import { ENV, FONT_FAMILY, getFontFamilyFallbacks, - isAndroid, - isIOS, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; @@ -1266,62 +1264,6 @@ 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; -}; - type FEATURE_FLAGS = { COMPLEX_BINDINGS: boolean; }; diff --git a/packages/element/src/resizeTest.ts b/packages/element/src/resizeTest.ts index 411dcf9a7b..4257d4a7e8 100644 --- a/packages/element/src/resizeTest.ts +++ b/packages/element/src/resizeTest.ts @@ -5,17 +5,20 @@ 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, Device, Zoom } from "@excalidraw/excalidraw/types"; +import type { AppState, Zoom } from "@excalidraw/excalidraw/types"; import { getElementAbsoluteCoords } from "./bounds"; import { getTransformHandlesFromCoords, getTransformHandles, - getOmitSidesForDevice, + getOmitSidesForEditorInterface, canResizeFromSides, } from "./transformHandles"; import { isImageElement, isLinearElement } from "./typeChecks"; @@ -51,7 +54,7 @@ export const resizeTest = ( y: number, zoom: Zoom, pointerType: PointerType, - device: Device, + editorInterface: EditorInterface, ): MaybeTransformHandleType => { if (!appState.selectedElementIds[element.id]) { return false; @@ -63,7 +66,7 @@ export const resizeTest = ( zoom, elementsMap, pointerType, - getOmitSidesForDevice(device), + getOmitSidesForEditorInterface(editorInterface), ); if ( @@ -86,7 +89,7 @@ export const resizeTest = ( return filter[0] as TransformHandleType; } - if (canResizeFromSides(device)) { + if (canResizeFromSides(editorInterface)) { const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( element, elementsMap, @@ -132,7 +135,7 @@ export const getElementWithTransformHandleType = ( zoom: Zoom, pointerType: PointerType, elementsMap: ElementsMap, - device: Device, + editorInterface: EditorInterface, ) => { return elements.reduce((result, element) => { if (result) { @@ -146,7 +149,7 @@ export const getElementWithTransformHandleType = ( scenePointerY, zoom, pointerType, - device, + editorInterface, ); return transformHandleType ? { element, transformHandleType } : null; }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); @@ -160,14 +163,14 @@ export const getTransformHandleTypeFromCoords = < scenePointerY: number, zoom: Zoom, pointerType: PointerType, - device: Device, + editorInterface: EditorInterface, ): MaybeTransformHandleType => { const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], 0 as Radians, zoom, pointerType, - getOmitSidesForDevice(device), + getOmitSidesForEditorInterface(editorInterface), ); const found = Object.keys(transformHandles).find((key) => { @@ -183,7 +186,7 @@ export const getTransformHandleTypeFromCoords = < return found as MaybeTransformHandleType; } - if (canResizeFromSides(device)) { + if (canResizeFromSides(editorInterface)) { const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; diff --git a/packages/element/src/transformHandles.ts b/packages/element/src/transformHandles.ts index b311e3af83..4a9e5f167c 100644 --- a/packages/element/src/transformHandles.ts +++ b/packages/element/src/transformHandles.ts @@ -1,8 +1,6 @@ import { DEFAULT_TRANSFORM_HANDLE_SPACING, - isAndroid, - isIOS, - isMobileOrTablet, + type EditorInterface, } from "@excalidraw/common"; import { pointFrom, pointRotateRads } from "@excalidraw/math"; @@ -10,7 +8,6 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math"; import type { - Device, InteractiveCanvasAppState, Zoom, } from "@excalidraw/excalidraw/types"; @@ -112,20 +109,21 @@ const generateTransformHandle = ( return [xx - width / 2, yy - height / 2, width, height]; }; -export const canResizeFromSides = (device: Device) => { - if (device.viewport.isMobile) { - return false; - } - - if (device.isTouchScreen && (isAndroid || isIOS)) { +export const canResizeFromSides = (editorInterface: EditorInterface) => { + if ( + editorInterface.formFactor === "phone" && + editorInterface.userAgent.isMobileDevice + ) { return false; } return true; }; -export const getOmitSidesForDevice = (device: Device) => { - if (canResizeFromSides(device)) { +export const getOmitSidesForEditorInterface = ( + editorInterface: EditorInterface, +) => { + if (canResizeFromSides(editorInterface)) { return DEFAULT_OMIT_SIDES; } @@ -330,6 +328,7 @@ export const getTransformHandles = ( export const hasBoundingBox = ( elements: readonly NonDeletedExcalidrawElement[], appState: InteractiveCanvasAppState, + editorInterface: EditorInterface, ) => { if (appState.selectedLinearElement?.isEditing) { return false; @@ -348,5 +347,5 @@ export const hasBoundingBox = ( // on mobile/tablet we currently don't show bbox because of resize issues // (also prob best for simplicity's sake) - return element.points.length > 2 && !isMobileOrTablet(); + return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice; }; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 66fa2fd8a5..f9c57a2851 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -83,7 +83,6 @@ export const actionChangeViewBackgroundColor = register>({ elements={elements} appState={appState} updateData={updateData} - compactMode={appState.stylesPanelMode === "compact"} /> ); }, diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 50f100104e..9821abadc8 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -30,6 +30,8 @@ import { getSelectedElements, isSomeElementSelected } from "../scene"; import { TrashIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; +import { useStylesPanelMode } from ".."; + import { register } from "./register"; import type { AppClassProperties, AppState } from "../types"; @@ -303,22 +305,25 @@ export const actionDeleteSelected = register({ keyTest: (event, appState, elements) => (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) && !event[KEYS.CTRL_OR_CMD], - PanelComponent: ({ elements, appState, updateData }) => ( - updateData(null)} - disabled={ - !isSomeElementSelected(getNonDeletedElements(elements), appState) - } - style={{ - ...(appState.stylesPanelMode === "mobile" && - appState.openPopup !== "compactOtherProperties" - ? MOBILE_ACTION_BUTTON_BG - : {}), - }} - /> - ), + PanelComponent: ({ elements, appState, updateData, app }) => { + const isMobile = useStylesPanelMode() === "mobile"; + + return ( + updateData(null)} + disabled={ + !isSomeElementSelected(getNonDeletedElements(elements), appState) + } + style={{ + ...(isMobile && appState.openPopup !== "compactOtherProperties" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} + /> + ); + }, }); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 69508a0228..462803d205 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -27,6 +27,8 @@ import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; import { getShortcutKey } from "../shortcut"; +import { useStylesPanelMode } from ".."; + import { register } from "./register"; export const actionDuplicateSelection = register({ @@ -107,24 +109,27 @@ export const actionDuplicateSelection = register({ }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, - PanelComponent: ({ elements, appState, updateData }) => ( - updateData(null)} - disabled={ - !isSomeElementSelected(getNonDeletedElements(elements), appState) - } - style={{ - ...(appState.stylesPanelMode === "mobile" && - appState.openPopup !== "compactOtherProperties" - ? MOBILE_ACTION_BUTTON_BG - : {}), - }} - /> - ), + PanelComponent: ({ elements, appState, updateData, app }) => { + const isMobile = useStylesPanelMode() === "mobile"; + + return ( + updateData(null)} + disabled={ + !isSomeElementSelected(getNonDeletedElements(elements), appState) + } + style={{ + ...(isMobile && appState.openPopup !== "compactOtherProperties" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} + /> + ); + }, }); diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 1604d3849c..e47a5bb84c 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -11,7 +11,7 @@ import { CaptureUpdateAction } from "@excalidraw/element"; import type { Theme } from "@excalidraw/element/types"; -import { useDevice } from "../components/App"; +import { useEditorInterface } from "../components/App"; import { CheckboxItem } from "../components/CheckboxItem"; import { DarkModeToggle } from "../components/DarkModeToggle"; import { ProjectName } from "../components/ProjectName"; @@ -248,7 +248,7 @@ export const actionSaveFileToDisk = register({ icon={saveAs} title={t("buttons.saveAs")} aria-label={t("buttons.saveAs")} - showAriaLabel={useDevice().editor.isMobile} + showAriaLabel={useEditorInterface().formFactor === "phone"} hidden={!nativeFileSystemSupported} onClick={() => updateData(null)} data-testid="save-as-button" diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index a1971f527c..0232bb33e3 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -18,6 +18,8 @@ import { HistoryChangedEvent } from "../history"; import { useEmitter } from "../hooks/useEmitter"; import { t } from "../i18n"; +import { useStylesPanelMode } from ".."; + import type { History } from "../history"; import type { AppClassProperties, AppState } from "../types"; import type { Action, ActionResult } from "./types"; @@ -73,7 +75,7 @@ export const createUndoAction: ActionCreator = (history) => ({ ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, - PanelComponent: ({ appState, updateData, data }) => { + PanelComponent: ({ appState, updateData, data, app }) => { const { isUndoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, new HistoryChangedEvent( @@ -81,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({ history.isRedoStackEmpty, ), ); + const isMobile = useStylesPanelMode() === "mobile"; return ( ({ disabled={isUndoStackEmpty} data-testid="button-undo" style={{ - ...(appState.stylesPanelMode === "mobile" - ? MOBILE_ACTION_BUTTON_BG - : {}), + ...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}), }} /> ); @@ -114,7 +115,7 @@ export const createRedoAction: ActionCreator = (history) => ({ keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), - PanelComponent: ({ appState, updateData, data }) => { + PanelComponent: ({ appState, updateData, data, app }) => { const { isRedoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, new HistoryChangedEvent( @@ -122,6 +123,7 @@ export const createRedoAction: ActionCreator = (history) => ({ history.isRedoStackEmpty, ), ); + const isMobile = useStylesPanelMode() === "mobile"; return ( ({ disabled={isRedoStackEmpty} data-testid="button-redo" style={{ - ...(appState.stylesPanelMode === "mobile" - ? MOBILE_ACTION_BUTTON_BG - : {}), + ...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}), }} /> ); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 35f6f271f8..4206de0074 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -59,6 +59,8 @@ import { toggleLinePolygonState, } from "@excalidraw/element"; +import { deriveStylesPanelMode } from "@excalidraw/common"; + import type { LocalPoint } from "@excalidraw/math"; import type { @@ -82,9 +84,6 @@ import { RadioSelection } from "../components/RadioSelection"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { FontPicker } from "../components/FontPicker/FontPicker"; import { IconPicker } from "../components/IconPicker"; -// TODO barnabasmolnar/editor-redesign -// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, -// ArrowHead icons import { Range } from "../components/Range"; import { ArrowheadArrowIcon, @@ -151,6 +150,15 @@ import type { AppClassProperties, AppState, Primitive } from "../types"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; +const getStylesPanelInfo = (app: AppClassProperties) => { + const stylesPanelMode = deriveStylesPanelMode(app.editorInterface); + return { + stylesPanelMode, + isCompact: stylesPanelMode !== "full", + isMobile: stylesPanelMode === "mobile", + } as const; +}; + export const changeProperty = ( elements: readonly ExcalidrawElement[], appState: AppState, @@ -331,35 +339,35 @@ export const actionChangeStrokeColor = register< : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, app, data }) => ( - <> - {appState.stylesPanelMode === "full" && ( - - )} - element.strokeColor, - true, - (hasSelection) => - !hasSelection ? appState.currentItemStrokeColor : null, + PanelComponent: ({ elements, appState, updateData, app, data }) => { + const { stylesPanelMode } = getStylesPanelInfo(app); + + return ( + <> + {stylesPanelMode === "full" && ( + )} - onChange={(color) => updateData({ currentItemStrokeColor: color })} - elements={elements} - appState={appState} - updateData={updateData} - compactMode={ - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile" - } - /> - - ), + element.strokeColor, + true, + (hasSelection) => + !hasSelection ? appState.currentItemStrokeColor : null, + )} + onChange={(color) => updateData({ currentItemStrokeColor: color })} + elements={elements} + appState={appState} + updateData={updateData} + /> + + ); + }, }); export const actionChangeBackgroundColor = register< @@ -416,35 +424,37 @@ export const actionChangeBackgroundColor = register< captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app, data }) => ( - <> - {appState.stylesPanelMode === "full" && ( - - )} - element.backgroundColor, - true, - (hasSelection) => - !hasSelection ? appState.currentItemBackgroundColor : null, + PanelComponent: ({ elements, appState, updateData, app, data }) => { + const { stylesPanelMode } = getStylesPanelInfo(app); + + return ( + <> + {stylesPanelMode === "full" && ( + )} - onChange={(color) => updateData({ currentItemBackgroundColor: color })} - elements={elements} - appState={appState} - updateData={updateData} - compactMode={ - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile" - } - /> - - ), + element.backgroundColor, + true, + (hasSelection) => + !hasSelection ? appState.currentItemBackgroundColor : null, + )} + onChange={(color) => + updateData({ currentItemBackgroundColor: color }) + } + elements={elements} + appState={appState} + updateData={updateData} + /> + + ); + }, }); export const actionChangeFillStyle = register({ @@ -455,7 +465,9 @@ export const actionChangeFillStyle = register({ trackEvent( "element", "changeFillStyle", - `${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`, + `${value} (${ + app.editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); return { elements: changeProperty(elements, appState, (el) => @@ -735,78 +747,81 @@ export const actionChangeFontSize = register( value, ); }, - PanelComponent: ({ elements, appState, updateData, app, data }) => ( -
- {t("labels.fontSize")} -
- { - if (isTextElement(element)) { - return element.fontSize; - } - const boundTextElement = getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), + PanelComponent: ({ elements, appState, updateData, app, data }) => { + const { isCompact } = getStylesPanelInfo(app); + + return ( +
+ {t("labels.fontSize")} +
+ { + if (isTextElement(element)) { + return element.fontSize; + } + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + if (boundTextElement) { + return boundTextElement.fontSize; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontSize || DEFAULT_FONT_SIZE, + )} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + isCompact, + !!appState.editingTextElement, + data?.onPreventClose, ); - if (boundTextElement) { - return boundTextElement.fontSize; - } - return null; - }, - (element) => - isTextElement(element) || - getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ) !== null, - (hasSelection) => - hasSelection - ? null - : appState.currentItemFontSize || DEFAULT_FONT_SIZE, - )} - onChange={(value) => { - withCaretPositionPreservation( - () => updateData(value), - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile", - !!appState.editingTextElement, - data?.onPreventClose, - ); - }} - /> -
-
- ), + }} + /> +
+
+ ); + }, }, ); @@ -1074,6 +1089,7 @@ export const actionChangeFontFamily = register<{ // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them const [batchedData, setBatchedData] = useState({}); const isUnmounted = useRef(true); + const { stylesPanelMode, isCompact } = getStylesPanelInfo(app); const selectedFontFamily = useMemo(() => { const getFontFamily = ( @@ -1146,14 +1162,14 @@ export const actionChangeFontFamily = register<{ return ( <> - {appState.stylesPanelMode === "full" && ( + {stylesPanelMode === "full" && ( {t("labels.fontFamily")} )} { withCaretPositionPreservation( () => { @@ -1165,8 +1181,7 @@ export const actionChangeFontFamily = register<{ // defensive clear so immediate close won't abuse the cached elements cachedElementsRef.current.clear(); }, - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile", + isCompact, !!appState.editingTextElement, ); }} @@ -1241,11 +1256,7 @@ export const actionChangeFontFamily = register<{ cachedElementsRef.current.clear(); // Refocus text editor when font picker closes if we were editing text - if ( - (appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile") && - appState.editingTextElement - ) { + if (isCompact && appState.editingTextElement) { restoreCaretPosition(null); // Just refocus without saved position } } @@ -1292,6 +1303,7 @@ export const actionChangeTextAlign = register({ }, PanelComponent: ({ elements, appState, updateData, app, data }) => { const elementsMap = app.scene.getNonDeletedElementsMap(); + const { isCompact } = getStylesPanelInfo(app); return (
@@ -1344,8 +1356,7 @@ export const actionChangeTextAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile", + isCompact, !!appState.editingTextElement, data?.onPreventClose, ); @@ -1392,6 +1403,7 @@ export const actionChangeVerticalAlign = register({ }; }, PanelComponent: ({ elements, appState, updateData, app, data }) => { + const { isCompact } = getStylesPanelInfo(app); return (
@@ -1444,8 +1456,7 @@ export const actionChangeVerticalAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile", + isCompact, !!appState.editingTextElement, data?.onPreventClose, ); diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index f3314bf35e..adac253e22 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -37,7 +37,9 @@ const trackAction = ( trackEvent( action.trackEvent.category, action.trackEvent.action || action.name, - `${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`, + `${source} (${ + app.editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); } } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 138f533bd7..087b1b795e 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -128,7 +128,6 @@ export const getDefaultAppState = (): Omit< lockedMultiSelections: {}, activeLockedId: null, bindMode: "orbit", - stylesPanelMode: "full", }; }; @@ -255,7 +254,6 @@ const APP_STATE_STORAGE_CONF = (< lockedMultiSelections: { browser: true, export: true, server: true }, activeLockedId: { browser: false, export: false, server: false }, bindMode: { browser: true, export: false, server: false }, - stylesPanelMode: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 48ec4dc9a2..c5b8d6ae5f 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -53,7 +53,11 @@ import { getToolbarTools } from "./shapes"; import "./Actions.scss"; -import { useDevice, useExcalidrawContainer } from "./App"; +import { + useEditorInterface, + useStylesPanelMode, + useExcalidrawContainer, +} from "./App"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { ToolPopover } from "./ToolPopover"; @@ -151,7 +155,7 @@ export const SelectedShapeActions = ({ const isEditingTextOrNewElement = Boolean( appState.editingTextElement || appState.newElement, ); - const device = useDevice(); + const editorInterface = useEditorInterface(); const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const showFillIcons = @@ -292,8 +296,10 @@ export const SelectedShapeActions = ({
{t("labels.actions")}
- {!device.editor.isMobile && renderAction("duplicateSelection")} - {!device.editor.isMobile && renderAction("deleteSelectedElements")} + {editorInterface.formFactor !== "phone" && + renderAction("duplicateSelection")} + {editorInterface.formFactor !== "phone" && + renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} {showLinkIcon && renderAction("hyperlink")} @@ -1041,6 +1047,9 @@ export const ShapesSwitcher = ({ UIOptions: AppProps["UIOptions"]; }) => { const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); + const stylesPanelMode = useStylesPanelMode(); + const isFullStylesPanel = stylesPanelMode === "full"; + const isCompactStylesPanel = stylesPanelMode === "compact"; const SELECTION_TOOLS = [ { @@ -1058,7 +1067,7 @@ export const ShapesSwitcher = ({ const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; const lassoToolSelected = - app.state.stylesPanelMode === "full" && + isFullStylesPanel && activeTool.type === "lasso" && app.state.preferredSelectionTool.type !== "lasso"; @@ -1091,7 +1100,7 @@ export const ShapesSwitcher = ({ // use a ToolPopover for selection/lasso toggle as well if ( (value === "selection" || value === "lasso") && - app.state.stylesPanelMode === "compact" + isCompactStylesPanel ) { return ( {t("toolBar.laser")} - {app.state.stylesPanelMode === "full" && ( + {isFullStylesPanel && ( app.setActiveTool({ type: "lasso" })} icon={LassoIcon} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7122d8b6f0..d3dda4941b 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, @@ -100,13 +97,17 @@ import { DOUBLE_TAP_POSITION_THRESHOLD, BIND_MODE_TIMEOUT, invariant, - isMobileOrTablet, - MQ_MAX_MOBILE, - MQ_MIN_TABLET, - MQ_MAX_TABLET, - MQ_MAX_HEIGHT_LANDSCAPE, - MQ_MAX_WIDTH_LANDSCAPE, getFeatureFlag, + createUserAgentDescriptor, + getFormFactor, + deriveStylesPanelMode, + isIOS, + isBrave, + isSafari, + type EditorInterface, + type StylesPanelMode, + loadDesktopUIModePreference, + setDesktopUIMode, } from "@excalidraw/common"; import { @@ -466,7 +467,6 @@ import type { LibraryItems, PointerDownState, SceneData, - Device, FrameNameBoundsCache, SidebarName, SidebarTabName, @@ -487,19 +487,20 @@ import type { Action, ActionResult } from "../actions/types"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); -const deviceContextInitialValue = { - viewport: { - isMobile: false, - isLandscape: false, - }, - editor: { - isMobile: false, - canFitSidebar: false, - }, +const editorInterfaceContextInitialValue: EditorInterface = { + formFactor: "desktop", + desktopUIMode: "full", + userAgent: createUserAgentDescriptor( + typeof navigator !== "undefined" ? navigator.userAgent : "", + ), isTouchScreen: false, + canFitSidebar: false, + isLandscape: true, }; -const DeviceContext = React.createContext(deviceContextInitialValue); -DeviceContext.displayName = "DeviceContext"; +const EditorInterfaceContext = React.createContext( + editorInterfaceContextInitialValue, +); +EditorInterfaceContext.displayName = "EditorInterfaceContext"; export const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; @@ -535,7 +536,10 @@ ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; export const useApp = () => useContext(AppContext); export const useAppProps = () => useContext(AppPropsContext); -export const useDevice = () => useContext(DeviceContext); +export const useEditorInterface = () => + useContext(EditorInterfaceContext); +export const useStylesPanelMode = () => + deriveStylesPanelMode(useEditorInterface()); export const useExcalidrawContainer = () => useContext(ExcalidrawContainerContext); export const useExcalidrawElements = () => @@ -583,7 +587,10 @@ class App extends React.Component { rc: RoughCanvas; unmounted: boolean = false; actionManager: ActionManager; - device: Device = deviceContextInitialValue; + editorInterface: EditorInterface = editorInterfaceContextInitialValue; + private stylesPanelMode: StylesPanelMode = deriveStylesPanelMode( + editorInterfaceContextInitialValue, + ); private excalidrawContainerRef = React.createRef(); @@ -700,6 +707,9 @@ class App extends React.Component { height: window.innerHeight, }; + this.refreshEditorInterface(); + this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); + this.id = nanoid(); this.library = new Library(this); this.actionManager = new ActionManager( @@ -746,6 +756,7 @@ class App extends React.Component { setActiveTool: this.setActiveTool, setCursor: this.setCursor, resetCursor: this.resetCursor, + getEditorInterface: () => this.editorInterface, updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, onChange: (cb) => this.onChangeEmitter.on(cb), @@ -1912,7 +1923,7 @@ class App extends React.Component { "excalidraw--view-mode": this.state.viewModeEnabled || this.state.openDialog?.name === "elementLinkSelector", - "excalidraw--mobile": this.device.editor.isMobile, + "excalidraw--mobile": this.editorInterface.formFactor === "phone", })} style={{ ["--ui-pointerEvents" as any]: shouldBlockPointerEvents @@ -1934,7 +1945,7 @@ class App extends React.Component { - + { renderScrollbars={ this.props.renderScrollbars === true } - device={this.device} + editorInterface={this.editorInterface} renderInteractiveSceneCallback={ this.renderInteractiveSceneCallback } @@ -2199,7 +2210,7 @@ class App extends React.Component { - + @@ -2716,7 +2727,8 @@ class App extends React.Component { if (!scene.appState.preferredSelectionTool.initialized) { scene.appState.preferredSelectionTool = { - type: this.device.editor.isMobile ? "lasso" : "selection", + type: + this.editorInterface.formFactor === "phone" ? "lasso" : "selection", initialized: true, }; } @@ -2776,44 +2788,14 @@ class App extends React.Component { } }; - private isMobileBreakpoint = (width: number, height: number) => { + private getFormFactor = (editorWidth: number, editorHeight: number) => { return ( - width <= MQ_MAX_MOBILE || - (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) + this.props.UIOptions.formFactor ?? + getFormFactor(editorWidth, editorHeight) ); }; - private 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; - }; - - private refreshViewportBreakpoints = () => { - const container = this.excalidrawContainerRef.current; - if (!container) { - return; - } - - const { width: editorWidth, height: editorHeight } = - container.getBoundingClientRect(); - - const prevViewportState = this.device.viewport; - - const nextViewportState = updateObject(prevViewportState, { - isLandscape: editorWidth > editorHeight, - isMobile: this.isMobileBreakpoint(editorWidth, editorHeight), - }); - - if (prevViewportState !== nextViewportState) { - this.device = { ...this.device, viewport: nextViewportState }; - return true; - } - return false; - }; - - private refreshEditorBreakpoints = () => { + public refreshEditorInterface = () => { const container = this.excalidrawContainerRef.current; if (!container) { return; @@ -2822,47 +2804,56 @@ class App extends React.Component { const { width: editorWidth, height: editorHeight } = container.getBoundingClientRect(); + const storedDesktopUIMode = loadDesktopUIModePreference(); + const userAgentDescriptor = createUserAgentDescriptor( + typeof navigator !== "undefined" ? navigator.userAgent : "", + ); + // allow host app to control formFactor and desktopUIMode via props const sidebarBreakpoint = this.props.UIOptions.dockedSidebarBreakpoint != null ? this.props.UIOptions.dockedSidebarBreakpoint : MQ_RIGHT_SIDEBAR_MIN_WIDTH; - - const prevEditorState = this.device.editor; - - const nextEditorState = updateObject(prevEditorState, { - isMobile: this.isMobileBreakpoint(editorWidth, editorHeight), + const nextEditorInterface = updateObject(this.editorInterface, { + desktopUIMode: + this.props.UIOptions.desktopUIMode ?? + storedDesktopUIMode ?? + this.editorInterface.desktopUIMode, + formFactor: this.getFormFactor(editorWidth, editorHeight), + userAgent: userAgentDescriptor, canFitSidebar: editorWidth > sidebarBreakpoint, + isLandscape: editorWidth > editorHeight, }); - const stylesPanelMode = - // NOTE: we could also remove the isMobileOrTablet check here and - // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP) - // but not too narrow (> MQ_MAX_WIDTH_MOBILE) - this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() - ? "compact" - : this.isMobileBreakpoint(editorWidth, editorHeight) - ? "mobile" - : "full"; + this.editorInterface = nextEditorInterface; + this.reconcileStylesPanelMode(nextEditorInterface); + }; - // also check if we need to update the app state - this.setState((prevState) => ({ - stylesPanelMode, - // reset to box selection mode if the UI changes to full - // where you'd not be able to change the mode yourself currently - preferredSelectionTool: - stylesPanelMode === "full" - ? { - type: "selection", - initialized: true, - } - : prevState.preferredSelectionTool, - })); - - if (prevEditorState !== nextEditorState) { - this.device = { ...this.device, editor: nextEditorState }; - return true; + private reconcileStylesPanelMode = (nextEditorInterface: EditorInterface) => { + const nextStylesPanelMode = deriveStylesPanelMode(nextEditorInterface); + if (nextStylesPanelMode === this.stylesPanelMode) { + return; } - return false; + + const prevStylesPanelMode = this.stylesPanelMode; + this.stylesPanelMode = nextStylesPanelMode; + + if (prevStylesPanelMode !== "full" && nextStylesPanelMode === "full") { + this.setState((prevState) => ({ + preferredSelectionTool: { + type: "selection", + initialized: true, + }, + })); + } + }; + + /** TO BE USED LATER */ + private setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => { + const nextMode = setDesktopUIMode(mode); + this.editorInterface = updateObject(this.editorInterface, { + desktopUIMode: nextMode, + }); + this.reconcileStylesPanelMode(this.editorInterface); }; private clearImageShapeCache(filesMap?: BinaryFiles) { @@ -2934,19 +2925,9 @@ class App extends React.Component { this.focusContainer(); } - if ( - // bounding rects don't work in tests so updating - // the state on init would result in making the test enviro run - // in mobile breakpoint (0 width/height), making everything fail - !isTestEnv() - ) { - this.refreshViewportBreakpoints(); - this.refreshEditorBreakpoints(); - } - if (supportsResizeObserver && this.excalidrawContainerRef.current) { this.resizeObserver = new ResizeObserver(() => { - this.refreshEditorBreakpoints(); + this.refreshEditorInterface(); this.updateDOMRect(); }); this.resizeObserver?.observe(this.excalidrawContainerRef.current); @@ -3000,11 +2981,8 @@ class App extends React.Component { this.scene .getElementsIncludingDeleted() .forEach((element) => ShapeCache.delete(element)); - this.refreshViewportBreakpoints(); + this.refreshEditorInterface(); this.updateDOMRect(); - if (!supportsResizeObserver) { - this.refreshEditorBreakpoints(); - } this.setState({}); }); @@ -3163,13 +3141,6 @@ class App extends React.Component { this.setState({ showWelcomeScreen: true }); } - if ( - prevProps.UIOptions.dockedSidebarBreakpoint !== - this.props.UIOptions.dockedSidebarBreakpoint - ) { - this.refreshEditorBreakpoints(); - } - const hasFollowedPersonLeft = prevState.userToFollow && !this.state.collaborators.has(prevState.userToFollow.socketId); @@ -3524,7 +3495,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; @@ -3549,7 +3521,8 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files, - position: isMobileOrTablet() ? "center" : "cursor", + position: + this.editorInterface.formFactor === "desktop" ? "cursor" : "center", }); return; @@ -3775,7 +3748,7 @@ class App extends React.Component { // from library, not when pasting from clipboard. Alas. openSidebar: this.state.openSidebar && - this.device.editor.canFitSidebar && + this.editorInterface.canFitSidebar && editorJotaiStore.get(isSidebarDockedAtom) ? this.state.openSidebar : null, @@ -3973,7 +3946,7 @@ class App extends React.Component { !isPlainPaste && textElements.length > 1 && PLAIN_PASTE_TOAST_SHOWN === false && - !this.device.editor.isMobile + this.editorInterface.formFactor !== "phone" ) { this.setToast({ message: t("toast.pasteAsSingleElement", { @@ -4005,7 +3978,9 @@ class App extends React.Component { trackEvent( "toolbar", "toggleLock", - `${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`, + `${source} (${ + this.editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); } this.setState((prevState) => { @@ -4357,12 +4332,7 @@ class App extends React.Component { } if (appState) { - this.setState({ - ...appState, - // keep existing stylesPanelMode as it needs to be preserved - // or set at startup - stylesPanelMode: this.state.stylesPanelMode, - } as Pick | null); + this.setState(appState as Pick | null); } if (elements) { @@ -4937,7 +4907,9 @@ class App extends React.Component { "toolbar", shape, `keyboard (${ - this.device.editor.isMobile ? "mobile" : "desktop" + this.editorInterface.formFactor === "phone" + ? "mobile" + : "desktop" })`, ); } @@ -5520,7 +5492,7 @@ class App extends React.Component { // caret (i.e. deselect). There's not much use for always selecting // the text on edit anyway (and users can select-all from contextmenu // if needed) - autoSelect: !this.device.isTouchScreen, + autoSelect: !this.editorInterface.isTouchScreen, }); // deselect all other elements when inserting text this.deselectElements(); @@ -5683,7 +5655,7 @@ class App extends React.Component { if ( considerBoundingBox && this.state.selectedElementIds[element.id] && - hasBoundingBox([element], this.state) + hasBoundingBox([element], this.state, this.editorInterface) ) { // if hitting the bounding box, return early // but if not, we should check for other cases as well (e.g. frame name) @@ -6153,7 +6125,7 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), this.state, pointFrom(scenePointer.x, scenePointer.y), - this.device.editor.isMobile, + this.editorInterface.formFactor === "phone", ) ) { return element; @@ -6188,7 +6160,7 @@ class App extends React.Component { elementsMap, this.state, pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y), - this.device.editor.isMobile, + this.editorInterface.formFactor === "phone", ); const lastPointerUpCoords = viewportCoordsToSceneCoords( this.lastPointerUpEvent!, @@ -6199,7 +6171,7 @@ class App extends React.Component { elementsMap, this.state, pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y), - this.device.editor.isMobile, + this.editorInterface.formFactor === "phone", ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { hideHyperlinkToolip(); @@ -6609,7 +6581,8 @@ class App extends React.Component { // better way of showing them is found !( isLinearElement(selectedElements[0]) && - (isMobileOrTablet() || selectedElements[0].points.length === 2) + (this.editorInterface.userAgent.isMobileDevice || + selectedElements[0].points.length === 2) ) ) { const elementWithTransformHandleType = @@ -6621,7 +6594,7 @@ class App extends React.Component { this.state.zoom, event.pointerType, this.scene.getNonDeletedElementsMap(), - this.device, + this.editorInterface, ); if ( elementWithTransformHandleType && @@ -6645,7 +6618,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, - this.device, + this.editorInterface, ); if (transformHandleType) { setCursor( @@ -7038,10 +7011,12 @@ class App extends React.Component { } if ( - !this.device.isTouchScreen && + !this.editorInterface.isTouchScreen && ["pen", "touch"].includes(event.pointerType) ) { - this.device = updateObject(this.device, { isTouchScreen: true }); + this.editorInterface = updateObject(this.editorInterface, { + isTouchScreen: true, + }); } if (isPanning) { @@ -7175,12 +7150,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 ) { @@ -7373,7 +7349,7 @@ class App extends React.Component { const clicklength = event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); - if (this.device.editor.isMobile && clicklength < 300) { + if (this.editorInterface.formFactor === "phone" && clicklength < 300) { const hitElement = this.getElementAtPosition( scenePointer.x, scenePointer.y, @@ -7392,7 +7368,7 @@ class App extends React.Component { } } - if (this.device.isTouchScreen) { + if (this.editorInterface.isTouchScreen) { const hitElement = this.getElementAtPosition( scenePointer.x, scenePointer.y, @@ -7422,7 +7398,7 @@ class App extends React.Component { ) { this.handleEmbeddableCenterClick(this.hitLinkElement); } else { - this.redirectToLink(event, this.device.isTouchScreen); + this.redirectToLink(event, this.editorInterface.isTouchScreen); } } else if (this.state.viewModeEnabled) { this.setState({ @@ -7747,7 +7723,8 @@ class App extends React.Component { !isElbowArrow(selectedElements[0]) && !( isLinearElement(selectedElements[0]) && - (isMobileOrTablet() || selectedElements[0].points.length === 2) + (this.editorInterface.userAgent.isMobileDevice || + selectedElements[0].points.length === 2) ) && !( this.state.selectedLinearElement && @@ -7763,7 +7740,7 @@ class App extends React.Component { this.state.zoom, event.pointerType, this.scene.getNonDeletedElementsMap(), - this.device, + this.editorInterface, ); if (elementWithTransformHandleType != null) { if ( @@ -7792,7 +7769,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, - this.device, + this.editorInterface, ); } if (pointerDownState.resize.handleType) { @@ -9134,7 +9111,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; @@ -9983,7 +9963,7 @@ class App extends React.Component { newElement && !multiElement ) { - if (this.device.isTouchScreen) { + if (this.editorInterface.isTouchScreen) { const FIXED_DELTA_X = Math.min( (this.state.width * 0.7) / this.state.zoom.value, 100, @@ -11821,7 +11801,7 @@ class App extends React.Component { } const zIndexActions: ContextMenuItems = - this.state.stylesPanelMode === "full" + this.editorInterface.formFactor === "desktop" ? [ CONTEXT_MENU_SEPARATOR, actionSendBackward, diff --git a/packages/excalidraw/components/ColorPicker/ColorInput.tsx b/packages/excalidraw/components/ColorPicker/ColorInput.tsx index 557f9c1c00..7de0af41e4 100644 --- a/packages/excalidraw/components/ColorPicker/ColorInput.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorInput.tsx @@ -6,7 +6,7 @@ import { KEYS } from "@excalidraw/common"; import { getShortcutKey } from "../..//shortcut"; import { useAtom } from "../../editor-jotai"; import { t } from "../../i18n"; -import { useDevice } from "../App"; +import { useEditorInterface } from "../App"; import { activeEyeDropperAtom } from "../EyeDropper"; import { eyeDropperIcon } from "../icons"; @@ -30,7 +30,7 @@ export const ColorInput = ({ colorPickerType, placeholder, }: ColorInputProps) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); const [innerValue, setInnerValue] = useState(color); const [activeSection, setActiveColorPickerSection] = useAtom( activeColorPickerSectionAtom, @@ -99,7 +99,7 @@ export const ColorInput = ({ placeholder={placeholder} /> {/* TODO reenable on mobile with a better UX */} - {!device.editor.isMobile && ( + {editorInterface.formFactor !== "phone" && ( <>
void; - compactMode?: boolean; } const ColorPickerPopupContent = ({ @@ -100,6 +99,9 @@ const ColorPickerPopupContent = ({ getOpenPopup: () => AppState["openPopup"]; }) => { const { container } = useExcalidrawContainer(); + const stylesPanelMode = useStylesPanelMode(); + const isCompactMode = stylesPanelMode !== "full"; + const isMobileMode = stylesPanelMode === "mobile"; const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); @@ -216,11 +218,8 @@ const ColorPickerPopupContent = ({ type={type} elements={elements} updateData={updateData} - showTitle={ - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile" - } - showHotKey={appState.stylesPanelMode !== "mobile"} + showTitle={isCompactMode} + showHotKey={!isMobileMode} > {colorInputJSX} @@ -235,7 +234,6 @@ const ColorPickerTrigger = ({ label, color, type, - stylesPanelMode, mode = "background", onToggle, editingTextElement, @@ -243,11 +241,13 @@ const ColorPickerTrigger = ({ color: string | null; label: string; type: ColorPickerType; - stylesPanelMode?: AppState["stylesPanelMode"]; mode?: "background" | "stroke"; onToggle: () => void; editingTextElement?: boolean; }) => { + const stylesPanelMode = useStylesPanelMode(); + const isCompactMode = stylesPanelMode !== "full"; + const isMobileMode = stylesPanelMode === "mobile"; const handleClick = (e: React.MouseEvent) => { // use pointerdown so we run before outside-close logic e.preventDefault(); @@ -268,9 +268,8 @@ const ColorPickerTrigger = ({ "is-transparent": !color || color === "transparent", "has-outline": !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), - "compact-sizing": - stylesPanelMode === "compact" || stylesPanelMode === "mobile", - "mobile-border": stylesPanelMode === "mobile", + "compact-sizing": isCompactMode, + "mobile-border": isMobileMode, })} aria-label={label} style={color ? { "--swatch-color": color } : undefined} @@ -283,22 +282,20 @@ const ColorPickerTrigger = ({ onClick={handleClick} >
{!color && slashIcon}
- {(stylesPanelMode === "compact" || stylesPanelMode === "mobile") && - color && - mode === "stroke" && ( -
- - {strokeIcon} - -
- )} + {isCompactMode && color && mode === "stroke" && ( +
+ + {strokeIcon} + +
+ )} ); }; @@ -318,10 +315,8 @@ export const ColorPicker = ({ useEffect(() => { openRef.current = appState.openPopup; }, [appState.openPopup]); - const compactMode = - type !== "canvasBackground" && - (appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile"); + const stylesPanelMode = useStylesPanelMode(); + const isCompactMode = stylesPanelMode !== "full"; return (
@@ -329,10 +324,10 @@ export const ColorPicker = ({ role="dialog" aria-modal="true" className={clsx("color-picker-container", { - "color-picker-container--no-top-picks": compactMode, + "color-picker-container--no-top-picks": isCompactMode, })} > - {!compactMode && ( + {!isCompactMode && ( )} - {!compactMode && } + {!isCompactMode && } { @@ -354,7 +349,6 @@ export const ColorPicker = ({ color={color} label={label} type={type} - stylesPanelMode={appState.stylesPanelMode} mode={type === "elementStroke" ? "stroke" : "background"} editingTextElement={!!appState.editingTextElement} onToggle={() => { diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index c6a8c19609..0840dd803d 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -903,7 +903,7 @@ function CommandPaletteInner({ ref={inputRef} /> - {!app.device.viewport.isMobile && ( + {app.editorInterface.formFactor !== "phone" && (
{t("commandPalette.shortcuts.select")} @@ -937,7 +937,7 @@ function CommandPaletteInner({ onClick={(event) => executeCommand(lastUsed, event)} disabled={!isCommandAvailable(lastUsed)} onMouseMove={() => setCurrentCommand(lastUsed)} - showShortcut={!app.device.viewport.isMobile} + showShortcut={app.editorInterface.formFactor !== "phone"} appState={uiAppState} />
@@ -955,7 +955,7 @@ function CommandPaletteInner({ isSelected={command.label === currentCommand?.label} onClick={(event) => executeCommand(command, event)} onMouseMove={() => setCurrentCommand(command)} - showShortcut={!app.device.viewport.isMobile} + showShortcut={app.editorInterface.formFactor !== "phone"} appState={uiAppState} size={category === "Library" ? "large" : "small"} /> diff --git a/packages/excalidraw/components/Dialog.tsx b/packages/excalidraw/components/Dialog.tsx index 00ae2be0cb..55109d07f3 100644 --- a/packages/excalidraw/components/Dialog.tsx +++ b/packages/excalidraw/components/Dialog.tsx @@ -9,7 +9,7 @@ import { t } from "../i18n"; import { useExcalidrawContainer, - useDevice, + useEditorInterface, useExcalidrawSetAppState, } from "./App"; import { Island } from "./Island"; @@ -51,7 +51,7 @@ export const Dialog = (props: DialogProps) => { const [islandNode, setIslandNode] = useCallbackRefState(); const [lastActiveElement] = useState(document.activeElement); const { id } = useExcalidrawContainer(); - const isFullscreen = useDevice().viewport.isMobile; + const isFullscreen = useEditorInterface().formFactor === "phone"; useEffect(() => { if (!islandNode) { diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index ff59f05ece..a6202f0227 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -20,7 +20,12 @@ import type { ValueOf } from "@excalidraw/common/utility-types"; import { Fonts } from "../../fonts"; import { t } from "../../i18n"; -import { useApp, useAppProps, useExcalidrawContainer } from "../App"; +import { + useApp, + useAppProps, + useExcalidrawContainer, + useStylesPanelMode, +} from "../App"; import { PropertiesPopover } from "../PropertiesPopover"; import { QuickSearch } from "../QuickSearch"; import { ScrollableList } from "../ScrollableList"; @@ -93,6 +98,7 @@ export const FontPickerList = React.memo( const app = useApp(); const { fonts } = app; const { showDeprecatedFonts } = useAppProps(); + const stylesPanelMode = useStylesPanelMode(); const [searchTerm, setSearchTerm] = useState(""); const inputRef = useRef(null); @@ -338,7 +344,7 @@ export const FontPickerList = React.memo( onKeyDown={onKeyDown} preventAutoFocusOnTouch={!!app.state.editingTextElement} > - {app.state.stylesPanelMode === "full" && ( + {stylesPanelMode === "full" && ( const getHints = ({ appState, isMobile, - device, + editorInterface, app, }: HintViewerProps): null | string | string[] => { const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; @@ -62,7 +64,7 @@ const getHints = ({ }); } - if (appState.openSidebar && !device.editor.canFitSidebar) { + if (appState.openSidebar && !editorInterface.canFitSidebar) { return null; } @@ -236,13 +238,13 @@ const getHints = ({ export const HintViewer = ({ appState, isMobile, - device, + editorInterface, app, }: HintViewerProps) => { const hints = getHints({ appState, isMobile, - device, + editorInterface, app, }); diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 13c69cf8fd..fab4f109b8 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai"; import { getLanguage, t } from "../i18n"; import Collapsible from "./Stats/Collapsible"; -import { useDevice, useExcalidrawContainer } from "./App"; +import { useEditorInterface, useExcalidrawContainer } from "./App"; import "./IconPicker.scss"; @@ -38,7 +38,7 @@ function Picker({ onClose: () => void; numberOfOptionsToAlwaysShow?: number; }) { - const device = useDevice(); + const editorInterface = useEditorInterface(); const { container } = useExcalidrawContainer(); const handleKeyDown = (event: React.KeyboardEvent) => { @@ -153,7 +153,7 @@ function Picker({ ); }; - const isMobile = device.editor.isMobile; + const isMobile = editorInterface.formFactor === "phone"; return ( { - const device = useDevice(); + const editorInterface = useEditorInterface(); + const stylesPanelMode = useStylesPanelMode(); + const isCompactStylesPanel = stylesPanelMode === "compact"; const tunnels = useInitializeTunnels(); - const spacing = - appState.stylesPanelMode === "compact" - ? { - menuTopGap: 4, - toolbarColGap: 4, - toolbarRowGap: 1, - toolbarInnerRowGap: 0.5, - islandPadding: 1, - collabMarginLeft: 8, - } - : { - menuTopGap: 6, - toolbarColGap: 4, - toolbarRowGap: 1, - toolbarInnerRowGap: 1, - islandPadding: 1, - collabMarginLeft: 8, - }; + const spacing = isCompactStylesPanel + ? { + menuTopGap: 4, + toolbarColGap: 4, + toolbarRowGap: 1, + toolbarInnerRowGap: 0.5, + islandPadding: 1, + collabMarginLeft: 8, + } + : { + menuTopGap: 6, + toolbarColGap: 4, + toolbarRowGap: 1, + toolbarInnerRowGap: 1, + islandPadding: 1, + collabMarginLeft: 8, + }; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; @@ -237,7 +238,7 @@ const LayerUI = ({ ); const renderSelectedShapeActions = () => { - const isCompactMode = appState.stylesPanelMode === "compact"; + const isCompactMode = isCompactStylesPanel; return (
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} @@ -334,14 +335,13 @@ const LayerUI = ({ padding={spacing.islandPadding} className={clsx("App-toolbar", { "zen-mode": appState.zenModeEnabled, - "App-toolbar--compact": - appState.stylesPanelMode === "compact", + "App-toolbar--compact": isCompactStylesPanel, })} > {heading} @@ -407,8 +407,7 @@ const LayerUI = ({ "layer-ui__wrapper__top-right zen-mode-transition", { "transition-right": appState.zenModeEnabled, - "layer-ui__wrapper__top-right--compact": - appState.stylesPanelMode === "compact", + "layer-ui__wrapper__top-right--compact": isCompactStylesPanel, }, )} > @@ -418,7 +417,10 @@ const LayerUI = ({ userToFollow={appState.userToFollow?.socketId || null} /> )} - {renderTopRightUI?.(device.editor.isMobile, appState)} + {renderTopRightUI?.( + editorInterface.formFactor === "phone", + appState, + )} {!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && // hide button when sidebar docked @@ -449,7 +451,9 @@ const LayerUI = ({ trackEvent( "sidebar", `toggleDock (${docked ? "dock" : "undock"})`, - `(${device.editor.isMobile ? "mobile" : "desktop"})`, + `(${ + editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); }} /> @@ -477,13 +481,15 @@ const LayerUI = ({ trackEvent( "sidebar", `${DEFAULT_SIDEBAR.name} (open)`, - `button (${device.editor.isMobile ? "mobile" : "desktop"})`, + `button (${ + editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); } }} tab={DEFAULT_SIDEBAR.defaultTab} > - {appState.stylesPanelMode === "full" && + {stylesPanelMode === "full" && appState.width >= MQ_MIN_WIDTH_DESKTOP && t("toolBar.library")} @@ -497,7 +503,7 @@ const LayerUI = ({ {appState.errorMessage} )} - {eyeDropperState && !device.editor.isMobile && ( + {eyeDropperState && editorInterface.formFactor !== "phone" && ( { @@ -577,7 +583,7 @@ const LayerUI = ({ } /> )} - {device.editor.isMobile && ( + {editorInterface.formFactor === "phone" && ( )} - {!device.editor.isMobile && ( + {editorInterface.formFactor !== "phone" && ( <>
void; }) { - const device = useDevice(); + const editorInterface = useEditorInterface(); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -392,7 +392,7 @@ export default function LibraryMenuItems({ ref={searchInputRef} type="search" className={clsx("library-menu-items-container__search", { - hideCancelButton: !device.editor.isMobile, + hideCancelButton: editorInterface.formFactor !== "phone", })} placeholder={t("library.search.inputPlaceholder")} value={searchInputValue} diff --git a/packages/excalidraw/components/LibraryUnit.tsx b/packages/excalidraw/components/LibraryUnit.tsx index 36607910e5..7d6a599526 100644 --- a/packages/excalidraw/components/LibraryUnit.tsx +++ b/packages/excalidraw/components/LibraryUnit.tsx @@ -3,7 +3,7 @@ import { memo, useRef, useState } from "react"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; -import { useDevice } from "./App"; +import { useEditorInterface } from "./App"; import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; @@ -36,7 +36,7 @@ export const LibraryUnit = memo( const svg = useLibraryItemSvg(id, elements, svgCache, ref); const [isHovered, setIsHovered] = useState(false); - const isMobile = useDevice().editor.isMobile; + const isMobile = useEditorInterface().formFactor === "phone"; const adder = isPending && (
{PlusIcon}
); diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index ccedd87a02..151d8eff16 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -4,7 +4,7 @@ import React, { type ReactNode } from "react"; import { isInteractive } from "@excalidraw/common"; -import { useDevice } from "./App"; +import { useEditorInterface } from "./App"; import { Island } from "./Island"; interface PropertiesPopoverProps { @@ -39,9 +39,9 @@ export const PropertiesPopover = React.forwardRef< }, ref, ) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); const isMobilePortrait = - device.editor.isMobile && !device.viewport.isLandscape; + editorInterface.formFactor === "phone" && !editorInterface.isLandscape; return ( @@ -56,7 +56,8 @@ export const PropertiesPopover = React.forwardRef< collisionBoundary={container ?? undefined} style={{ zIndex: "var(--zIndex-ui-styles-popup)", - marginLeft: device.editor.isMobile ? "0.5rem" : undefined, + marginLeft: + editorInterface.formFactor === "phone" ? "0.5rem" : undefined, }} onPointerLeave={onPointerLeave} onKeyDown={onKeyDown} @@ -64,7 +65,7 @@ export const PropertiesPopover = React.forwardRef< onPointerDownOutside={onPointerDownOutside} onOpenAutoFocus={(e) => { // prevent auto-focus on touch devices to avoid keyboard popup - if (preventAutoFocusOnTouch && device.isTouchScreen) { + if (preventAutoFocusOnTouch && editorInterface.isTouchScreen) { e.preventDefault(); } }} diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx index 5f0ca487f2..8226eacef5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx @@ -20,7 +20,7 @@ import { import { useUIAppState } from "../../context/ui-appState"; import { atom, useSetAtom } from "../../editor-jotai"; import { useOutsideClick } from "../../hooks/useOutsideClick"; -import { useDevice, useExcalidrawSetAppState } from "../App"; +import { useEditorInterface, useExcalidrawSetAppState } from "../App"; import { Island } from "../Island"; import { SidebarHeader } from "./SidebarHeader"; @@ -96,7 +96,7 @@ export const SidebarInner = forwardRef( return islandRef.current!; }); - const device = useDevice(); + const editorInterface = useEditorInterface(); const closeLibrary = useCallback(() => { const isDialogOpen = !!document.querySelector(".Dialog"); @@ -117,11 +117,11 @@ export const SidebarInner = forwardRef( if ((event.target as Element).closest(".sidebar-trigger")) { return; } - if (!docked || !device.editor.canFitSidebar) { + if (!docked || !editorInterface.canFitSidebar) { closeLibrary(); } }, - [closeLibrary, docked, device.editor.canFitSidebar], + [closeLibrary, docked, editorInterface.canFitSidebar], ), ); @@ -129,7 +129,7 @@ export const SidebarInner = forwardRef( const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === KEYS.ESCAPE && - (!docked || !device.editor.canFitSidebar) + (!docked || !editorInterface.canFitSidebar) ) { closeLibrary(); } @@ -138,7 +138,7 @@ export const SidebarInner = forwardRef( return () => { document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); }; - }, [closeLibrary, docked, device.editor.canFitSidebar]); + }, [closeLibrary, docked, editorInterface.canFitSidebar]); return ( { - const device = useDevice(); + const editorInterface = useEditorInterface(); const props = useContext(SidebarPropsContext); const renderDockButton = !!( - device.editor.canFitSidebar && props.shouldRenderDockButton + editorInterface.canFitSidebar && props.shouldRenderDockButton ); return ( diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 4a1c08aa78..05e19b17e8 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 { AnimationController } from "@excalidraw/excalidraw/renderer/animation"; @@ -25,7 +26,6 @@ import type { import type { AppClassProperties, AppState, - Device, InteractiveCanvasAppState, } from "../../types"; import type { DOMAttributes } from "react"; @@ -42,7 +42,7 @@ type InteractiveCanvasProps = { scale: number; appState: InteractiveCanvasAppState; renderScrollbars: boolean; - device: Device; + editorInterface: EditorInterface; app: AppClassProperties; renderInteractiveSceneCallback: ( data: RenderInteractiveSceneCallback, @@ -148,6 +148,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { allElementsMap: props.allElementsMap, scale: window.devicePixelRatio, appState: props.appState, + editorInterface: props.editorInterface, renderConfig: { remotePointerViewportCoords, remotePointerButton, @@ -159,7 +160,6 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { // NOTE not memoized on so we don't rerender on cursor move lastViewportPosition: props.app.lastViewportPosition, }, - device: props.device, callback: props.renderInteractiveSceneCallback, animationState: { bindingHighlight: undefined, diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index 5bbb41763b..28f7c78fc0 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -5,7 +5,7 @@ import { EVENT, KEYS } from "@excalidraw/common"; import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useStable } from "../../hooks/useStable"; -import { useDevice } from "../App"; +import { useEditorInterface } from "../App"; import { Island } from "../Island"; import Stack from "../Stack"; @@ -29,7 +29,7 @@ const MenuContent = ({ style?: React.CSSProperties; placement?: "top" | "bottom"; }) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); const menuRef = useRef(null); const callbacksRef = useStable({ onClickOutside }); @@ -59,7 +59,7 @@ const MenuContent = ({ }, [callbacksRef]); const classNames = clsx(`dropdown-menu ${className}`, { - "dropdown-menu--mobile": device.editor.isMobile, + "dropdown-menu--mobile": editorInterface.formFactor === "phone", "dropdown-menu--placement-top": placement === "top", }).trim(); @@ -73,13 +73,8 @@ const MenuContent = ({ > {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} - {device.editor.isMobile ? ( - - {children} - + {editorInterface.formFactor === "phone" ? ( + {children} ) : ( { - const device = useDevice(); + const editorInterface = useEditorInterface(); return ( <> {icon &&
{icon}
}
{children}
- {shortcut && !device.editor.isMobile && ( + {shortcut && editorInterface.formFactor !== "phone" && (
{shortcut}
)} diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index 14bfe1a904..d8177c50e0 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -1,4 +1,4 @@ -import { useDevice } from "../App"; +import { useEditorInterface } from "../App"; import { RadioGroup } from "../RadioGroup"; type Props = { @@ -22,7 +22,7 @@ const DropdownMenuItemContentRadio = ({ children, name, }: Props) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); return ( <> @@ -37,7 +37,7 @@ const DropdownMenuItemContentRadio = ({ choices={choices} />
- {shortcut && !device.editor.isMobile && ( + {shortcut && editorInterface.formFactor !== "phone" && (
{shortcut}
diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx index a7301fb447..f43e4493b1 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; -import { useDevice } from "../App"; +import { useEditorInterface } from "../App"; const MenuTrigger = ({ className = "", @@ -14,12 +14,12 @@ const MenuTrigger = ({ onToggle: () => void; title?: string; } & Omit, "onSelect">) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); const classNames = clsx( `dropdown-menu-button ${className}`, "zen-mode-transition", { - "dropdown-menu-button--mobile": device.editor.isMobile, + "dropdown-menu-button--mobile": editorInterface.formFactor === "phone", }, ).trim(); return ( diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx index 5e380e4e6e..8e15bb0d54 100644 --- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -41,7 +41,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip"; import { t } from "../../i18n"; -import { useAppProps, useDevice, useExcalidrawAppState } from "../App"; +import { useAppProps, useEditorInterface, useExcalidrawAppState } from "../App"; import { ToolButton } from "../ToolButton"; import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons"; import { getSelectedElements } from "../../scene"; @@ -88,7 +88,7 @@ export const Hyperlink = ({ const elementsMap = scene.getNonDeletedElementsMap(); const appState = useExcalidrawAppState(); const appProps = useAppProps(); - const device = useDevice(); + const editorInterface = useEditorInterface(); const linkVal = element.link || ""; @@ -189,11 +189,11 @@ export const Hyperlink = ({ if ( isEditing && inputRef?.current && - !(device.viewport.isMobile || device.isTouchScreen) + !(editorInterface.formFactor === "phone" || editorInterface.isTouchScreen) ) { inputRef.current.select(); } - }, [isEditing, device.viewport.isMobile, device.isTouchScreen]); + }, [isEditing, editorInterface.formFactor, editorInterface.isTouchScreen]); useEffect(() => { let timeoutId: number | null = null; 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 (