From 47cbb5b6fbeba23fbdfb72729159d723f1176746 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Tue, 4 Nov 2025 09:34:17 +1100 Subject: [PATCH] refactor: single source of truths with editor interface (#10178) * refactor device to editor interface and derive styles panel * allow host app to control form factor and ui mode * add editor interface event listener * put new props inside UIOptions * refactor: move related apis into one file * expose getFormFactor * privatize the setting of desktop mode and fix snapshots * refactor and fix test * remove unimplemented code * export getFormFactor() * replace `getFormFactor` with `getEditorInterface` * remove dead & useless * comment * fix ts --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .../api/children-components/footer.mdx | 10 +- .../excalidraw/api/utils/utils-intro.md | 26 +- .../components/MobileFooter.tsx | 6 +- excalidraw-app/App.tsx | 4 + excalidraw-app/tests/MobileMenu.test.tsx | 21 +- packages/common/src/constants.ts | 46 --- packages/common/src/editorInterface.ts | 223 +++++++++++++ packages/common/src/index.ts | 1 + packages/common/src/keys.ts | 2 +- packages/common/src/utils.ts | 58 ---- packages/element/src/resizeTest.ts | 25 +- packages/element/src/transformHandles.ts | 25 +- packages/excalidraw/actions/actionCanvas.tsx | 1 - .../actions/actionDeleteSelected.tsx | 41 +-- .../actions/actionDuplicateSelection.tsx | 45 +-- packages/excalidraw/actions/actionExport.tsx | 4 +- packages/excalidraw/actions/actionHistory.tsx | 16 +- .../excalidraw/actions/actionProperties.tsx | 299 +++++++++--------- packages/excalidraw/actions/manager.tsx | 4 +- packages/excalidraw/appState.ts | 2 - packages/excalidraw/components/Actions.tsx | 23 +- packages/excalidraw/components/App.tsx | 278 ++++++++-------- .../components/ColorPicker/ColorInput.tsx | 6 +- .../components/ColorPicker/ColorPicker.tsx | 66 ++-- .../CommandPalette/CommandPalette.tsx | 6 +- packages/excalidraw/components/Dialog.tsx | 4 +- .../components/FontPicker/FontPickerList.tsx | 10 +- packages/excalidraw/components/HintViewer.tsx | 14 +- packages/excalidraw/components/IconPicker.tsx | 6 +- packages/excalidraw/components/LayerUI.tsx | 78 ++--- .../components/LibraryMenuItems.tsx | 6 +- .../excalidraw/components/LibraryUnit.tsx | 4 +- .../components/PropertiesPopover.tsx | 11 +- .../excalidraw/components/Sidebar/Sidebar.tsx | 12 +- .../components/Sidebar/SidebarHeader.tsx | 6 +- .../components/canvases/InteractiveCanvas.tsx | 7 +- .../dropdownMenu/DropdownMenuContent.tsx | 15 +- .../dropdownMenu/DropdownMenuItemContent.tsx | 6 +- .../DropdownMenuItemContentRadio.tsx | 6 +- .../dropdownMenu/DropdownMenuTrigger.tsx | 6 +- .../components/hyperlink/Hyperlink.tsx | 8 +- .../LiveCollaborationTrigger.tsx | 7 +- .../components/main-menu/MainMenu.tsx | 31 +- .../welcome-screen/WelcomeScreen.Center.tsx | 6 +- .../hooks/useCreatePortalContainer.ts | 11 +- packages/excalidraw/index.tsx | 10 +- .../excalidraw/renderer/interactiveScene.ts | 19 +- packages/excalidraw/scene/types.ts | 5 +- .../__snapshots__/contextmenu.test.tsx.snap | 17 - .../tests/__snapshots__/history.test.tsx.snap | 63 ---- .../regressionTests.test.tsx.snap | 156 +++------ .../excalidraw/tests/regressionTests.test.tsx | 10 +- packages/excalidraw/tests/test-utils.ts | 16 +- packages/excalidraw/types.ts | 25 +- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 8 +- .../tests/__snapshots__/export.test.ts.snap | 1 - 56 files changed, 914 insertions(+), 908 deletions(-) create mode 100644 packages/common/src/editorInterface.ts 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 dfbb69aa97..b6a451d988 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 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 7bf73c6581..69e854b0b0 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -20,8 +20,6 @@ import { ENV, FONT_FAMILY, getFontFamilyFallbacks, - isAndroid, - isIOS, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; @@ -1272,59 +1270,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 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 b0760fd8ba..b8f837b402 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 694f02b90c..cfc5e69e21 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"; @@ -320,22 +322,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 908e2463e9..cf7a58a98a 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"; @@ -242,7 +242,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 360220b1e2..c356456ac1 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -57,6 +57,8 @@ import { toggleLinePolygonState, } from "@excalidraw/element"; +import { deriveStylesPanelMode } from "@excalidraw/common"; + import type { LocalPoint } from "@excalidraw/math"; import type { @@ -80,9 +82,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, @@ -149,6 +148,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, @@ -327,35 +335,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({ @@ -410,35 +418,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({ @@ -449,7 +459,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) => @@ -715,78 +727,81 @@ export const actionChangeFontSize = register({ perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, () => value, 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, - ); - }} - /> -
-
- ), + }} + /> +
+
+ ); + }, }); export const actionDecreaseFontSize = register({ @@ -1048,6 +1063,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 = ( @@ -1120,14 +1136,14 @@ export const actionChangeFontFamily = register({ return ( <> - {appState.stylesPanelMode === "full" && ( + {stylesPanelMode === "full" && ( {t("labels.fontFamily")} )} { withCaretPositionPreservation( () => { @@ -1139,8 +1155,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, ); }} @@ -1215,11 +1230,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 } } @@ -1266,6 +1277,7 @@ export const actionChangeTextAlign = register({ }, PanelComponent: ({ elements, appState, updateData, app, data }) => { const elementsMap = app.scene.getNonDeletedElementsMap(); + const { isCompact } = getStylesPanelInfo(app); return (
@@ -1318,8 +1330,7 @@ export const actionChangeTextAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile", + isCompact, !!appState.editingTextElement, data?.onPreventClose, ); @@ -1366,6 +1377,7 @@ export const actionChangeVerticalAlign = register({ }; }, PanelComponent: ({ elements, appState, updateData, app, data }) => { + const { isCompact } = getStylesPanelInfo(app); return (
@@ -1418,8 +1430,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 96876e5854..7ec58fec12 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -127,7 +127,6 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, - stylesPanelMode: "full", }; }; @@ -253,7 +252,6 @@ const APP_STATE_STORAGE_CONF = (< searchMatches: { browser: false, export: false, server: false }, lockedMultiSelections: { browser: true, export: true, server: true }, activeLockedId: { browser: false, 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 470c72081e..db90022d2b 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, @@ -98,12 +95,16 @@ 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, + createUserAgentDescriptor, + getFormFactor, + deriveStylesPanelMode, + isIOS, + isBrave, + isSafari, + type EditorInterface, + type StylesPanelMode, + loadDesktopUIModePreference, + setDesktopUIMode, } from "@excalidraw/common"; import { @@ -460,7 +461,6 @@ import type { LibraryItems, PointerDownState, SceneData, - Device, FrameNameBoundsCache, SidebarName, SidebarTabName, @@ -481,19 +481,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; @@ -529,7 +530,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 = () => @@ -577,7 +581,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(); @@ -693,6 +700,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( @@ -739,6 +749,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), @@ -1567,7 +1578,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 @@ -1589,7 +1600,7 @@ class App extends React.Component { - + { renderScrollbars={ this.props.renderScrollbars === true } - device={this.device} + editorInterface={this.editorInterface} renderInteractiveSceneCallback={ this.renderInteractiveSceneCallback } @@ -1853,7 +1864,7 @@ class App extends React.Component { - + @@ -2370,7 +2381,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, }; } @@ -2430,44 +2442,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; @@ -2476,47 +2458,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) { @@ -2588,19 +2579,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); @@ -2654,11 +2635,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({}); }); @@ -2817,13 +2795,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); @@ -3178,7 +3149,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; @@ -3203,7 +3175,8 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files, - position: isMobileOrTablet() ? "center" : "cursor", + position: + this.editorInterface.formFactor === "desktop" ? "cursor" : "center", }); return; @@ -3429,7 +3402,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, @@ -3627,7 +3600,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", { @@ -3659,7 +3632,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) => { @@ -4011,12 +3986,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) { @@ -4594,7 +4564,9 @@ class App extends React.Component { "toolbar", shape, `keyboard (${ - this.device.editor.isMobile ? "mobile" : "desktop" + this.editorInterface.formFactor === "phone" + ? "mobile" + : "desktop" })`, ); } @@ -5100,7 +5072,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(); @@ -5263,7 +5235,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) @@ -5733,7 +5705,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; @@ -5768,7 +5740,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!, @@ -5779,7 +5751,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(); @@ -6171,7 +6143,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 = @@ -6183,7 +6156,7 @@ class App extends React.Component { this.state.zoom, event.pointerType, this.scene.getNonDeletedElementsMap(), - this.device, + this.editorInterface, ); if ( elementWithTransformHandleType && @@ -6207,7 +6180,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, - this.device, + this.editorInterface, ); if (transformHandleType) { setCursor( @@ -6593,10 +6566,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) { @@ -6730,12 +6705,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 ) { @@ -6919,7 +6895,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, @@ -6938,7 +6914,7 @@ class App extends React.Component { } } - if (this.device.isTouchScreen) { + if (this.editorInterface.isTouchScreen) { const hitElement = this.getElementAtPosition( scenePointer.x, scenePointer.y, @@ -6968,7 +6944,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({ @@ -7293,7 +7269,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 && @@ -7309,7 +7286,7 @@ class App extends React.Component { this.state.zoom, event.pointerType, this.scene.getNonDeletedElementsMap(), - this.device, + this.editorInterface, ); if (elementWithTransformHandleType != null) { if ( @@ -7338,7 +7315,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, - this.device, + this.editorInterface, ); } if (pointerDownState.resize.handleType) { @@ -8540,7 +8517,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; @@ -9388,7 +9368,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, @@ -11206,7 +11186,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 2ef757d5c5..3e922328b8 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; @@ -51,7 +53,7 @@ const getHints = ({ }); } - if (appState.openSidebar && !device.editor.canFitSidebar) { + if (appState.openSidebar && !editorInterface.canFitSidebar) { return null; } @@ -225,13 +227,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; @@ -236,7 +237,7 @@ const LayerUI = ({ ); const renderSelectedShapeActions = () => { - const isCompactMode = appState.stylesPanelMode === "compact"; + const isCompactMode = isCompactStylesPanel; return (
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} @@ -333,14 +334,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} @@ -406,8 +406,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, }, )} > @@ -417,7 +416,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 @@ -448,7 +450,9 @@ const LayerUI = ({ trackEvent( "sidebar", `toggleDock (${docked ? "dock" : "undock"})`, - `(${device.editor.isMobile ? "mobile" : "desktop"})`, + `(${ + editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); }} /> @@ -476,13 +480,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")} @@ -496,7 +502,7 @@ const LayerUI = ({ {appState.errorMessage} )} - {eyeDropperState && !device.editor.isMobile && ( + {eyeDropperState && editorInterface.formFactor !== "phone" && ( { @@ -575,7 +581,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 c375a2b168..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,7 +21,7 @@ import type { RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; -import type { AppState, Device, InteractiveCanvasAppState } from "../../types"; +import type { AppState, InteractiveCanvasAppState } from "../../types"; import type { DOMAttributes } from "react"; type InteractiveCanvasProps = { @@ -35,7 +36,7 @@ type InteractiveCanvasProps = { scale: number; appState: InteractiveCanvasAppState; renderScrollbars: boolean; - device: Device; + editorInterface: EditorInterface; renderInteractiveSceneCallback: ( data: RenderInteractiveSceneCallback, ) => void; @@ -146,7 +147,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { selectionColor, renderScrollbars: props.renderScrollbars, }, - device: props.device, + editorInterface: props.editorInterface, callback: props.renderInteractiveSceneCallback, }, isRenderThrottlingEnabled(), 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 (