Browse Source

feat: make device breakpoints more specific (#7243)

David Luzar 1 year ago
parent
commit
b1037b342d

+ 1 - 1
dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx

@@ -34,7 +34,7 @@ Open the `Menu` in the below playground and you will see the `custom footer` ren
 ```jsx live noInline
 ```jsx live noInline
 const MobileFooter = ({}) => {
 const MobileFooter = ({}) => {
   const device = useDevice();
   const device = useDevice();
-  if (device.isMobile) {
+  if (device.editor.isMobile) {
     return (
     return (
       <Footer>
       <Footer>
         <button
         <button

+ 1 - 2
dev-docs/docs/@excalidraw/excalidraw/api/utils/utils-intro.md

@@ -299,7 +299,7 @@ Open the `main menu` in the below example to view the footer.
 ```jsx live noInline
 ```jsx live noInline
 const MobileFooter = ({}) => {
 const MobileFooter = ({}) => {
   const device = useDevice();
   const device = useDevice();
-  if (device.isMobile) {
+  if (device.editor.isMobile) {
     return (
     return (
       <Footer>
       <Footer>
         <button
         <button
@@ -335,7 +335,6 @@ The `device` has the following `attributes`
 
 
 | Name | Type | Description |
 | Name | Type | Description |
 | --- | --- | --- |
 | --- | --- | --- |
-| `isSmScreen` | `boolean` | Set to `true` when the device small screen is small (Width < `640px` ) |
 | `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
 | `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
 | `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
 | `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
 | `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
 | `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |

+ 12 - 6
excalidraw-app/tests/MobileMenu.test.tsx

@@ -17,8 +17,10 @@ describe("Test MobileMenu", () => {
 
 
   beforeEach(async () => {
   beforeEach(async () => {
     await render(<ExcalidrawApp />);
     await render(<ExcalidrawApp />);
-    //@ts-ignore
-    h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
+    // @ts-ignore
+    h.app.refreshViewportBreakpoints();
+    // @ts-ignore
+    h.app.refreshEditorBreakpoints();
   });
   });
 
 
   afterAll(() => {
   afterAll(() => {
@@ -28,11 +30,15 @@ describe("Test MobileMenu", () => {
   it("should set device correctly", () => {
   it("should set device correctly", () => {
     expect(h.app.device).toMatchInlineSnapshot(`
     expect(h.app.device).toMatchInlineSnapshot(`
       {
       {
-        "canDeviceFitSidebar": false,
-        "isLandscape": true,
-        "isMobile": true,
-        "isSmScreen": false,
+        "editor": {
+          "canFitSidebar": false,
+          "isMobile": true,
+        },
         "isTouchScreen": false,
         "isTouchScreen": false,
+        "viewport": {
+          "isLandscape": false,
+          "isMobile": true,
+        },
       }
       }
     `);
     `);
   });
   });

+ 1 - 1
src/actions/actionExport.tsx

@@ -217,7 +217,7 @@ export const actionSaveFileToDisk = register({
       icon={saveAs}
       icon={saveAs}
       title={t("buttons.saveAs")}
       title={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
-      showAriaLabel={useDevice().isMobile}
+      showAriaLabel={useDevice().editor.isMobile}
       hidden={!nativeFileSystemSupported}
       hidden={!nativeFileSystemSupported}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
       data-testid="save-as-button"
       data-testid="save-as-button"

+ 1 - 1
src/actions/actionProperties.tsx

@@ -328,7 +328,7 @@ export const actionChangeFillStyle = register({
     trackEvent(
     trackEvent(
       "element",
       "element",
       "changeFillStyle",
       "changeFillStyle",
-      `${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
+      `${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
     );
     );
     return {
     return {
       elements: changeProperty(elements, appState, (el) =>
       elements: changeProperty(elements, appState, (el) =>

+ 1 - 1
src/actions/manager.tsx

@@ -29,7 +29,7 @@ const trackAction = (
           trackEvent(
           trackEvent(
             action.trackEvent.category,
             action.trackEvent.category,
             action.trackEvent.action || action.name,
             action.trackEvent.action || action.name,
-            `${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
+            `${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
           );
           );
         }
         }
       }
       }

+ 2 - 2
src/components/Actions.tsx

@@ -202,8 +202,8 @@ export const SelectedShapeActions = ({
         <fieldset>
         <fieldset>
           <legend>{t("labels.actions")}</legend>
           <legend>{t("labels.actions")}</legend>
           <div className="buttonList">
           <div className="buttonList">
-            {!device.isMobile && renderAction("duplicateSelection")}
-            {!device.isMobile && renderAction("deleteSelectedElements")}
+            {!device.editor.isMobile && renderAction("duplicateSelection")}
+            {!device.editor.isMobile && renderAction("deleteSelectedElements")}
             {renderAction("group")}
             {renderAction("group")}
             {renderAction("ungroup")}
             {renderAction("ungroup")}
             {showLinkIcon && renderAction("hyperlink")}
             {showLinkIcon && renderAction("hyperlink")}

+ 84 - 63
src/components/App.tsx

@@ -74,7 +74,6 @@ import {
   MQ_MAX_WIDTH_LANDSCAPE,
   MQ_MAX_WIDTH_LANDSCAPE,
   MQ_MAX_WIDTH_PORTRAIT,
   MQ_MAX_WIDTH_PORTRAIT,
   MQ_RIGHT_SIDEBAR_MIN_WIDTH,
   MQ_RIGHT_SIDEBAR_MIN_WIDTH,
-  MQ_SM_MAX_WIDTH,
   POINTER_BUTTON,
   POINTER_BUTTON,
   ROUNDNESS,
   ROUNDNESS,
   SCROLL_TIMEOUT,
   SCROLL_TIMEOUT,
@@ -381,11 +380,15 @@ const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
 
 
 const deviceContextInitialValue = {
 const deviceContextInitialValue = {
-  isSmScreen: false,
-  isMobile: false,
+  viewport: {
+    isMobile: false,
+    isLandscape: false,
+  },
+  editor: {
+    isMobile: false,
+    canFitSidebar: false,
+  },
   isTouchScreen: false,
   isTouchScreen: false,
-  canDeviceFitSidebar: false,
-  isLandscape: false,
 };
 };
 const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
 const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
 DeviceContext.displayName = "DeviceContext";
 DeviceContext.displayName = "DeviceContext";
@@ -436,6 +439,9 @@ export const useExcalidrawSetAppState = () =>
 export const useExcalidrawActionManager = () =>
 export const useExcalidrawActionManager = () =>
   useContext(ExcalidrawActionManagerContext);
   useContext(ExcalidrawActionManagerContext);
 
 
+const supportsResizeObserver =
+  typeof window !== "undefined" && "ResizeObserver" in window;
+
 let didTapTwice: boolean = false;
 let didTapTwice: boolean = false;
 let tappedTwiceTimer = 0;
 let tappedTwiceTimer = 0;
 let isHoldingSpace: boolean = false;
 let isHoldingSpace: boolean = false;
@@ -472,7 +478,6 @@ class App extends React.Component<AppProps, AppState> {
   unmounted: boolean = false;
   unmounted: boolean = false;
   actionManager: ActionManager;
   actionManager: ActionManager;
   device: Device = deviceContextInitialValue;
   device: Device = deviceContextInitialValue;
-  detachIsMobileMqHandler?: () => void;
 
 
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
 
 
@@ -1180,7 +1185,7 @@ class App extends React.Component<AppProps, AppState> {
       <div
       <div
         className={clsx("excalidraw excalidraw-container", {
         className={clsx("excalidraw excalidraw-container", {
           "excalidraw--view-mode": this.state.viewModeEnabled,
           "excalidraw--view-mode": this.state.viewModeEnabled,
-          "excalidraw--mobile": this.device.isMobile,
+          "excalidraw--mobile": this.device.editor.isMobile,
         })}
         })}
         style={{
         style={{
           ["--ui-pointerEvents" as any]:
           ["--ui-pointerEvents" as any]:
@@ -1657,20 +1662,62 @@ class App extends React.Component<AppProps, AppState> {
     });
     });
   };
   };
 
 
-  private refreshDeviceState = (container: HTMLDivElement) => {
-    const { width, height } = container.getBoundingClientRect();
+  private isMobileBreakpoint = (width: number, height: number) => {
+    return (
+      width < MQ_MAX_WIDTH_PORTRAIT ||
+      (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
+    );
+  };
+
+  private refreshViewportBreakpoints = () => {
+    const container = this.excalidrawContainerRef.current;
+    if (!container) {
+      return;
+    }
+
+    const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
+      document.body;
+
+    const prevViewportState = this.device.viewport;
+
+    const nextViewportState = updateObject(prevViewportState, {
+      isLandscape: viewportWidth > viewportHeight,
+      isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
+    });
+
+    if (prevViewportState !== nextViewportState) {
+      this.device = { ...this.device, viewport: nextViewportState };
+      return true;
+    }
+    return false;
+  };
+
+  private refreshEditorBreakpoints = () => {
+    const container = this.excalidrawContainerRef.current;
+    if (!container) {
+      return;
+    }
+
+    const { width: editorWidth, height: editorHeight } =
+      container.getBoundingClientRect();
+
     const sidebarBreakpoint =
     const sidebarBreakpoint =
       this.props.UIOptions.dockedSidebarBreakpoint != null
       this.props.UIOptions.dockedSidebarBreakpoint != null
         ? this.props.UIOptions.dockedSidebarBreakpoint
         ? this.props.UIOptions.dockedSidebarBreakpoint
         : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
         : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
-    this.device = updateObject(this.device, {
-      isLandscape: width > height,
-      isSmScreen: width < MQ_SM_MAX_WIDTH,
-      isMobile:
-        width < MQ_MAX_WIDTH_PORTRAIT ||
-        (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
-      canDeviceFitSidebar: width > sidebarBreakpoint,
+
+    const prevEditorState = this.device.editor;
+
+    const nextEditorState = updateObject(prevEditorState, {
+      isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
+      canFitSidebar: editorWidth > sidebarBreakpoint,
     });
     });
+
+    if (prevEditorState !== nextEditorState) {
+      this.device = { ...this.device, editor: nextEditorState };
+      return true;
+    }
+    return false;
   };
   };
 
 
   public async componentDidMount() {
   public async componentDidMount() {
@@ -1712,52 +1759,21 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
 
 
     if (
     if (
-      this.excalidrawContainerRef.current &&
       // bounding rects don't work in tests so updating
       // bounding rects don't work in tests so updating
       // the state on init would result in making the test enviro run
       // the state on init would result in making the test enviro run
       // in mobile breakpoint (0 width/height), making everything fail
       // in mobile breakpoint (0 width/height), making everything fail
       !isTestEnv()
       !isTestEnv()
     ) {
     ) {
-      this.refreshDeviceState(this.excalidrawContainerRef.current);
+      this.refreshViewportBreakpoints();
+      this.refreshEditorBreakpoints();
     }
     }
 
 
-    if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
+    if (supportsResizeObserver && this.excalidrawContainerRef.current) {
       this.resizeObserver = new ResizeObserver(() => {
       this.resizeObserver = new ResizeObserver(() => {
-        // recompute device dimensions state
-        // ---------------------------------------------------------------------
-        this.refreshDeviceState(this.excalidrawContainerRef.current!);
-        // refresh offsets
-        // ---------------------------------------------------------------------
+        this.refreshEditorBreakpoints();
         this.updateDOMRect();
         this.updateDOMRect();
       });
       });
       this.resizeObserver?.observe(this.excalidrawContainerRef.current);
       this.resizeObserver?.observe(this.excalidrawContainerRef.current);
-    } else if (window.matchMedia) {
-      const mdScreenQuery = window.matchMedia(
-        `(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
-      );
-      const smScreenQuery = window.matchMedia(
-        `(max-width: ${MQ_SM_MAX_WIDTH}px)`,
-      );
-      const canDeviceFitSidebarMediaQuery = window.matchMedia(
-        `(min-width: ${
-          // NOTE this won't update if a different breakpoint is supplied
-          // after mount
-          this.props.UIOptions.dockedSidebarBreakpoint != null
-            ? this.props.UIOptions.dockedSidebarBreakpoint
-            : MQ_RIGHT_SIDEBAR_MIN_WIDTH
-        }px)`,
-      );
-      const handler = () => {
-        this.excalidrawContainerRef.current!.getBoundingClientRect();
-        this.device = updateObject(this.device, {
-          isSmScreen: smScreenQuery.matches,
-          isMobile: mdScreenQuery.matches,
-          canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches,
-        });
-      };
-      mdScreenQuery.addListener(handler);
-      this.detachIsMobileMqHandler = () =>
-        mdScreenQuery.removeListener(handler);
     }
     }
 
 
     const searchParams = new URLSearchParams(window.location.search.slice(1));
     const searchParams = new URLSearchParams(window.location.search.slice(1));
@@ -1802,6 +1818,11 @@ class App extends React.Component<AppProps, AppState> {
     this.scene
     this.scene
       .getElementsIncludingDeleted()
       .getElementsIncludingDeleted()
       .forEach((element) => ShapeCache.delete(element));
       .forEach((element) => ShapeCache.delete(element));
+    this.refreshViewportBreakpoints();
+    this.updateDOMRect();
+    if (!supportsResizeObserver) {
+      this.refreshEditorBreakpoints();
+    }
     this.setState({});
     this.setState({});
   });
   });
 
 
@@ -1855,7 +1876,6 @@ class App extends React.Component<AppProps, AppState> {
       false,
       false,
     );
     );
 
 
-    this.detachIsMobileMqHandler?.();
     window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
     window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
   }
   }
 
 
@@ -1940,11 +1960,10 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
 
 
     if (
     if (
-      this.excalidrawContainerRef.current &&
       prevProps.UIOptions.dockedSidebarBreakpoint !==
       prevProps.UIOptions.dockedSidebarBreakpoint !==
-        this.props.UIOptions.dockedSidebarBreakpoint
+      this.props.UIOptions.dockedSidebarBreakpoint
     ) {
     ) {
-      this.refreshDeviceState(this.excalidrawContainerRef.current);
+      this.refreshEditorBreakpoints();
     }
     }
 
 
     if (
     if (
@@ -2410,7 +2429,7 @@ class App extends React.Component<AppProps, AppState> {
         // from library, not when pasting from clipboard. Alas.
         // from library, not when pasting from clipboard. Alas.
         openSidebar:
         openSidebar:
           this.state.openSidebar &&
           this.state.openSidebar &&
-          this.device.canDeviceFitSidebar &&
+          this.device.editor.canFitSidebar &&
           jotaiStore.get(isSidebarDockedAtom)
           jotaiStore.get(isSidebarDockedAtom)
             ? this.state.openSidebar
             ? this.state.openSidebar
             : null,
             : null,
@@ -2624,7 +2643,7 @@ class App extends React.Component<AppProps, AppState> {
       !isPlainPaste &&
       !isPlainPaste &&
       textElements.length > 1 &&
       textElements.length > 1 &&
       PLAIN_PASTE_TOAST_SHOWN === false &&
       PLAIN_PASTE_TOAST_SHOWN === false &&
-      !this.device.isMobile
+      !this.device.editor.isMobile
     ) {
     ) {
       this.setToast({
       this.setToast({
         message: t("toast.pasteAsSingleElement", {
         message: t("toast.pasteAsSingleElement", {
@@ -2658,7 +2677,7 @@ class App extends React.Component<AppProps, AppState> {
       trackEvent(
       trackEvent(
         "toolbar",
         "toolbar",
         "toggleLock",
         "toggleLock",
-        `${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
+        `${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
       );
       );
     }
     }
     this.setState((prevState) => {
     this.setState((prevState) => {
@@ -3153,7 +3172,9 @@ class App extends React.Component<AppProps, AppState> {
             trackEvent(
             trackEvent(
               "toolbar",
               "toolbar",
               shape,
               shape,
-              `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
+              `keyboard (${
+                this.device.editor.isMobile ? "mobile" : "desktop"
+              })`,
             );
             );
           }
           }
           this.setActiveTool({ type: shape });
           this.setActiveTool({ type: shape });
@@ -3887,7 +3908,7 @@ class App extends React.Component<AppProps, AppState> {
           element,
           element,
           this.state,
           this.state,
           [scenePointer.x, scenePointer.y],
           [scenePointer.x, scenePointer.y],
-          this.device.isMobile,
+          this.device.editor.isMobile,
         )
         )
       );
       );
     });
     });
@@ -3919,7 +3940,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       this.hitLinkElement,
       this.state,
       this.state,
       [lastPointerDownCoords.x, lastPointerDownCoords.y],
       [lastPointerDownCoords.x, lastPointerDownCoords.y],
-      this.device.isMobile,
+      this.device.editor.isMobile,
     );
     );
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
       this.lastPointerUpEvent!,
       this.lastPointerUpEvent!,
@@ -3929,7 +3950,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       this.hitLinkElement,
       this.state,
       this.state,
       [lastPointerUpCoords.x, lastPointerUpCoords.y],
       [lastPointerUpCoords.x, lastPointerUpCoords.y],
-      this.device.isMobile,
+      this.device.editor.isMobile,
     );
     );
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
       let url = this.hitLinkElement.link;
       let url = this.hitLinkElement.link;
@@ -4791,7 +4812,7 @@ class App extends React.Component<AppProps, AppState> {
     );
     );
     const clicklength =
     const clicklength =
       event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
       event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
-    if (this.device.isMobile && clicklength < 300) {
+    if (this.device.editor.isMobile && clicklength < 300) {
       const hitElement = this.getElementAtPosition(
       const hitElement = this.getElementAtPosition(
         scenePointer.x,
         scenePointer.x,
         scenePointer.y,
         scenePointer.y,

+ 1 - 1
src/components/ColorPicker/ColorInput.tsx

@@ -98,7 +98,7 @@ export const ColorInput = ({
         }}
         }}
       />
       />
       {/* TODO reenable on mobile with a better UX */}
       {/* TODO reenable on mobile with a better UX */}
-      {!device.isMobile && (
+      {!device.editor.isMobile && (
         <>
         <>
           <div
           <div
             style={{
             style={{

+ 11 - 3
src/components/ColorPicker/ColorPicker.tsx

@@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
   );
   );
 
 
   const { container } = useExcalidrawContainer();
   const { container } = useExcalidrawContainer();
-  const { isMobile, isLandscape } = useDevice();
+  const device = useDevice();
 
 
   const colorInputJSX = (
   const colorInputJSX = (
     <div>
     <div>
@@ -136,8 +136,16 @@ const ColorPickerPopupContent = ({
           updateData({ openPopup: null });
           updateData({ openPopup: null });
           setActiveColorPickerSection(null);
           setActiveColorPickerSection(null);
         }}
         }}
-        side={isMobile && !isLandscape ? "bottom" : "right"}
-        align={isMobile && !isLandscape ? "center" : "start"}
+        side={
+          device.editor.isMobile && !device.viewport.isLandscape
+            ? "bottom"
+            : "right"
+        }
+        align={
+          device.editor.isMobile && !device.viewport.isLandscape
+            ? "center"
+            : "start"
+        }
         alignOffset={-16}
         alignOffset={-16}
         sideOffset={20}
         sideOffset={20}
         style={{
         style={{

+ 1 - 1
src/components/Dialog.tsx

@@ -119,7 +119,7 @@ export const Dialog = (props: DialogProps) => {
           title={t("buttons.close")}
           title={t("buttons.close")}
           aria-label={t("buttons.close")}
           aria-label={t("buttons.close")}
         >
         >
-          {device.isMobile ? back : CloseIcon}
+          {device.editor.isMobile ? back : CloseIcon}
         </button>
         </button>
         <div className="Dialog__content">{props.children}</div>
         <div className="Dialog__content">{props.children}</div>
       </Island>
       </Island>

+ 1 - 1
src/components/HintViewer.tsx

@@ -22,7 +22,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
   const multiMode = appState.multiElement !== null;
 
 
-  if (appState.openSidebar && !device.canDeviceFitSidebar) {
+  if (appState.openSidebar && !device.editor.canFitSidebar) {
     return null;
     return null;
   }
   }
 
 

+ 8 - 8
src/components/LayerUI.tsx

@@ -246,7 +246,7 @@ const LayerUI = ({
                       >
                       >
                         <HintViewer
                         <HintViewer
                           appState={appState}
                           appState={appState}
-                          isMobile={device.isMobile}
+                          isMobile={device.editor.isMobile}
                           device={device}
                           device={device}
                           app={app}
                           app={app}
                         />
                         />
@@ -314,7 +314,7 @@ const LayerUI = ({
             )}
             )}
           >
           >
             <UserList collaborators={appState.collaborators} />
             <UserList collaborators={appState.collaborators} />
-            {renderTopRightUI?.(device.isMobile, appState)}
+            {renderTopRightUI?.(device.editor.isMobile, appState)}
             {!appState.viewModeEnabled &&
             {!appState.viewModeEnabled &&
               // hide button when sidebar docked
               // hide button when sidebar docked
               (!isSidebarDocked ||
               (!isSidebarDocked ||
@@ -335,7 +335,7 @@ const LayerUI = ({
           trackEvent(
           trackEvent(
             "sidebar",
             "sidebar",
             `toggleDock (${docked ? "dock" : "undock"})`,
             `toggleDock (${docked ? "dock" : "undock"})`,
-            `(${device.isMobile ? "mobile" : "desktop"})`,
+            `(${device.editor.isMobile ? "mobile" : "desktop"})`,
           );
           );
         }}
         }}
       />
       />
@@ -363,7 +363,7 @@ const LayerUI = ({
             trackEvent(
             trackEvent(
               "sidebar",
               "sidebar",
               `${DEFAULT_SIDEBAR.name} (open)`,
               `${DEFAULT_SIDEBAR.name} (open)`,
-              `button (${device.isMobile ? "mobile" : "desktop"})`,
+              `button (${device.editor.isMobile ? "mobile" : "desktop"})`,
             );
             );
           }
           }
         }}
         }}
@@ -380,7 +380,7 @@ const LayerUI = ({
           {appState.errorMessage}
           {appState.errorMessage}
         </ErrorDialog>
         </ErrorDialog>
       )}
       )}
-      {eyeDropperState && !device.isMobile && (
+      {eyeDropperState && !device.editor.isMobile && (
         <EyeDropper
         <EyeDropper
           colorPickerType={eyeDropperState.colorPickerType}
           colorPickerType={eyeDropperState.colorPickerType}
           onCancel={() => {
           onCancel={() => {
@@ -450,7 +450,7 @@ const LayerUI = ({
           }
           }
         />
         />
       )}
       )}
-      {device.isMobile && (
+      {device.editor.isMobile && (
         <MobileMenu
         <MobileMenu
           app={app}
           app={app}
           appState={appState}
           appState={appState}
@@ -469,14 +469,14 @@ const LayerUI = ({
           renderWelcomeScreen={renderWelcomeScreen}
           renderWelcomeScreen={renderWelcomeScreen}
         />
         />
       )}
       )}
-      {!device.isMobile && (
+      {!device.editor.isMobile && (
         <>
         <>
           <div
           <div
             className="layer-ui__wrapper"
             className="layer-ui__wrapper"
             style={
             style={
               appState.openSidebar &&
               appState.openSidebar &&
               isSidebarDocked &&
               isSidebarDocked &&
-              device.canDeviceFitSidebar
+              device.editor.canFitSidebar
                 ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
                 ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
                 : {}
                 : {}
             }
             }

+ 1 - 1
src/components/LibraryUnit.tsx

@@ -47,7 +47,7 @@ export const LibraryUnit = memo(
     }, [svg]);
     }, [svg]);
 
 
     const [isHovered, setIsHovered] = useState(false);
     const [isHovered, setIsHovered] = useState(false);
-    const isMobile = useDevice().isMobile;
+    const isMobile = useDevice().editor.isMobile;
     const adder = isPending && (
     const adder = isPending && (
       <div className="library-unit__adder">{PlusIcon}</div>
       <div className="library-unit__adder">{PlusIcon}</div>
     );
     );

+ 4 - 4
src/components/Sidebar/Sidebar.tsx

@@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
           if ((event.target as Element).closest(".sidebar-trigger")) {
           if ((event.target as Element).closest(".sidebar-trigger")) {
             return;
             return;
           }
           }
-          if (!docked || !device.canDeviceFitSidebar) {
+          if (!docked || !device.editor.canFitSidebar) {
             closeLibrary();
             closeLibrary();
           }
           }
         },
         },
-        [closeLibrary, docked, device.canDeviceFitSidebar],
+        [closeLibrary, docked, device.editor.canFitSidebar],
       ),
       ),
     );
     );
 
 
@@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
       const handleKeyDown = (event: KeyboardEvent) => {
       const handleKeyDown = (event: KeyboardEvent) => {
         if (
         if (
           event.key === KEYS.ESCAPE &&
           event.key === KEYS.ESCAPE &&
-          (!docked || !device.canDeviceFitSidebar)
+          (!docked || !device.editor.canFitSidebar)
         ) {
         ) {
           closeLibrary();
           closeLibrary();
         }
         }
@@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
       return () => {
       return () => {
         document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
         document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
       };
       };
-    }, [closeLibrary, docked, device.canDeviceFitSidebar]);
+    }, [closeLibrary, docked, device.editor.canFitSidebar]);
 
 
     return (
     return (
       <Island
       <Island

+ 1 - 1
src/components/Sidebar/SidebarHeader.tsx

@@ -18,7 +18,7 @@ export const SidebarHeader = ({
   const props = useContext(SidebarPropsContext);
   const props = useContext(SidebarPropsContext);
 
 
   const renderDockButton = !!(
   const renderDockButton = !!(
-    device.canDeviceFitSidebar && props.shouldRenderDockButton
+    device.editor.canFitSidebar && props.shouldRenderDockButton
   );
   );
 
 
   return (
   return (

+ 2 - 2
src/components/dropdownMenu/DropdownMenuContent.tsx

@@ -30,7 +30,7 @@ const MenuContent = ({
   });
   });
 
 
   const classNames = clsx(`dropdown-menu ${className}`, {
   const classNames = clsx(`dropdown-menu ${className}`, {
-    "dropdown-menu--mobile": device.isMobile,
+    "dropdown-menu--mobile": device.editor.isMobile,
   }).trim();
   }).trim();
 
 
   return (
   return (
@@ -43,7 +43,7 @@ const MenuContent = ({
       >
       >
         {/* the zIndex ensures this menu has higher stacking order,
         {/* the zIndex ensures this menu has higher stacking order,
     see https://github.com/excalidraw/excalidraw/pull/1445 */}
     see https://github.com/excalidraw/excalidraw/pull/1445 */}
-        {device.isMobile ? (
+        {device.editor.isMobile ? (
           <Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
           <Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
         ) : (
         ) : (
           <Island
           <Island

+ 1 - 1
src/components/dropdownMenu/DropdownMenuItemContent.tsx

@@ -14,7 +14,7 @@ const MenuItemContent = ({
     <>
     <>
       <div className="dropdown-menu-item__icon">{icon}</div>
       <div className="dropdown-menu-item__icon">{icon}</div>
       <div className="dropdown-menu-item__text">{children}</div>
       <div className="dropdown-menu-item__text">{children}</div>
-      {shortcut && !device.isMobile && (
+      {shortcut && !device.editor.isMobile && (
         <div className="dropdown-menu-item__shortcut">{shortcut}</div>
         <div className="dropdown-menu-item__shortcut">{shortcut}</div>
       )}
       )}
     </>
     </>

+ 1 - 1
src/components/dropdownMenu/DropdownMenuTrigger.tsx

@@ -18,7 +18,7 @@ const MenuTrigger = ({
     `dropdown-menu-button ${className}`,
     `dropdown-menu-button ${className}`,
     "zen-mode-transition",
     "zen-mode-transition",
     {
     {
-      "dropdown-menu-button--mobile": device.isMobile,
+      "dropdown-menu-button--mobile": device.editor.isMobile,
     },
     },
   ).trim();
   ).trim();
   return (
   return (

+ 2 - 2
src/components/main-menu/MainMenu.tsx

@@ -29,7 +29,7 @@ const MainMenu = Object.assign(
       const device = useDevice();
       const device = useDevice();
       const appState = useUIAppState();
       const appState = useUIAppState();
       const setAppState = useExcalidrawSetAppState();
       const setAppState = useExcalidrawSetAppState();
-      const onClickOutside = device.isMobile
+      const onClickOutside = device.editor.isMobile
         ? undefined
         ? undefined
         : () => setAppState({ openMenu: null });
         : () => setAppState({ openMenu: null });
 
 
@@ -54,7 +54,7 @@ const MainMenu = Object.assign(
               })}
               })}
             >
             >
               {children}
               {children}
-              {device.isMobile && appState.collaborators.size > 0 && (
+              {device.editor.isMobile && appState.collaborators.size > 0 && (
                 <fieldset className="UserList-Wrapper">
                 <fieldset className="UserList-Wrapper">
                   <legend>{t("labels.collaborators")}</legend>
                   <legend>{t("labels.collaborators")}</legend>
                   <UserList
                   <UserList

+ 1 - 1
src/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -21,7 +21,7 @@ const WelcomeScreenMenuItemContent = ({
     <>
     <>
       <div className="welcome-screen-menu-item__icon">{icon}</div>
       <div className="welcome-screen-menu-item__icon">{icon}</div>
       <div className="welcome-screen-menu-item__text">{children}</div>
       <div className="welcome-screen-menu-item__text">{children}</div>
-      {shortcut && !device.isMobile && (
+      {shortcut && !device.editor.isMobile && (
         <div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
         <div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
       )}
       )}
     </>
     </>

+ 0 - 2
src/constants.ts

@@ -220,8 +220,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
 
 
 // breakpoints
 // breakpoints
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------
-// sm screen
-export const MQ_SM_MAX_WIDTH = 640;
 // md screen
 // md screen
 export const MQ_MAX_WIDTH_PORTRAIT = 730;
 export const MQ_MAX_WIDTH_PORTRAIT = 730;
 export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
 export const MQ_MAX_WIDTH_LANDSCAPE = 1000;

+ 4 - 2
src/element/textWysiwyg.test.tsx

@@ -249,8 +249,10 @@ describe("textWysiwyg", () => {
 
 
     beforeEach(async () => {
     beforeEach(async () => {
       await render(<Excalidraw handleKeyboardGlobally={true} />);
       await render(<Excalidraw handleKeyboardGlobally={true} />);
-      //@ts-ignore
-      h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
+      // @ts-ignore
+      h.app.refreshViewportBreakpoints();
+      // @ts-ignore
+      h.app.refreshEditorBreakpoints();
 
 
       textElement = UI.createElement("text");
       textElement = UI.createElement("text");
 
 

+ 3 - 6
src/hooks/useCreatePortalContainer.ts

@@ -1,4 +1,4 @@
-import { useState, useRef, useLayoutEffect } from "react";
+import { useState, useLayoutEffect } from "react";
 import { useDevice, useExcalidrawContainer } from "../components/App";
 import { useDevice, useExcalidrawContainer } from "../components/App";
 import { useUIAppState } from "../context/ui-appState";
 import { useUIAppState } from "../context/ui-appState";
 
 
@@ -10,8 +10,6 @@ export const useCreatePortalContainer = (opts?: {
 
 
   const device = useDevice();
   const device = useDevice();
   const { theme } = useUIAppState();
   const { theme } = useUIAppState();
-  const isMobileRef = useRef(device.isMobile);
-  isMobileRef.current = device.isMobile;
 
 
   const { container: excalidrawContainer } = useExcalidrawContainer();
   const { container: excalidrawContainer } = useExcalidrawContainer();
 
 
@@ -19,11 +17,10 @@ export const useCreatePortalContainer = (opts?: {
     if (div) {
     if (div) {
       div.className = "";
       div.className = "";
       div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
       div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
-      div.classList.toggle("excalidraw--mobile", device.isMobile);
-      div.classList.toggle("excalidraw--mobile", isMobileRef.current);
+      div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
       div.classList.toggle("theme--dark", theme === "dark");
       div.classList.toggle("theme--dark", theme === "dark");
     }
     }
-  }, [div, theme, device.isMobile, opts?.className]);
+  }, [div, theme, device.editor.isMobile, opts?.className]);
 
 
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     const container = opts?.parentSelector
     const container = opts?.parentSelector

+ 4 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -19,6 +19,10 @@ Please add the latest change on the top under the correct section.
 - Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195)
 - Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195)
 - Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078)
 - Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078)
 
 
+#### BREAKING CHANGES
+
+- [`useDevice`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#usedevice) hook's return value was changed to differentiate between `editor` and `viewport` breakpoints. [#7243](https://github.com/excalidraw/excalidraw/pull/7243)
+
 ## 0.16.1 (2023-09-21)
 ## 0.16.1 (2023-09-21)
 
 
 ## Excalidraw Library
 ## Excalidraw Library

+ 1 - 1
src/packages/excalidraw/example/MobileFooter.tsx

@@ -8,7 +8,7 @@ const MobileFooter = ({
   excalidrawAPI: ExcalidrawImperativeAPI;
   excalidrawAPI: ExcalidrawImperativeAPI;
 }) => {
 }) => {
   const device = useDevice();
   const device = useDevice();
-  if (device.isMobile) {
+  if (device.editor.isMobile) {
     return (
     return (
       <Footer>
       <Footer>
         <CustomFooter excalidrawAPI={excalidrawAPI} />
         <CustomFooter excalidrawAPI={excalidrawAPI} />

+ 6 - 2
src/tests/test-utils.ts

@@ -173,14 +173,18 @@ export const withExcalidrawDimensions = async (
 ) => {
 ) => {
   mockBoundingClientRect(dimensions);
   mockBoundingClientRect(dimensions);
   // @ts-ignore
   // @ts-ignore
-  window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
+  h.app.refreshViewportBreakpoints();
+  // @ts-ignore
+  h.app.refreshEditorBreakpoints();
   window.h.app.refresh();
   window.h.app.refresh();
 
 
   await cb();
   await cb();
 
 
   restoreOriginalGetBoundingClientRect();
   restoreOriginalGetBoundingClientRect();
   // @ts-ignore
   // @ts-ignore
-  window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
+  h.app.refreshViewportBreakpoints();
+  // @ts-ignore
+  h.app.refreshEditorBreakpoints();
   window.h.app.refresh();
   window.h.app.refresh();
 };
 };
 
 

+ 8 - 4
src/types.ts

@@ -667,11 +667,15 @@ export type ExcalidrawImperativeAPI = {
 };
 };
 
 
 export type Device = Readonly<{
 export type Device = Readonly<{
-  isSmScreen: boolean;
-  isMobile: boolean;
+  viewport: {
+    isMobile: boolean;
+    isLandscape: boolean;
+  };
+  editor: {
+    isMobile: boolean;
+    canFitSidebar: boolean;
+  };
   isTouchScreen: boolean;
   isTouchScreen: boolean;
-  canDeviceFitSidebar: boolean;
-  isLandscape: boolean;
 }>;
 }>;
 
 
 type FrameNameBounds = {
 type FrameNameBounds = {