Ver código fonte

feat: use component dimensions to break to mobile (#3414)

Co-authored-by: Jed Fox <git@jedfox.com>
David Luzar 4 anos atrás
pai
commit
09dfd16b17

+ 1 - 1
src/actions/actionCanvas.tsx

@@ -8,7 +8,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
 import { newElementWith } from "../element/mutateElement";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { CODES, KEYS } from "../keys";
 import { getNormalizedZoom, getSelectedElements } from "../scene";
 import { centerScrollOn } from "../scene/scroll";

+ 1 - 1
src/actions/actionExport.tsx

@@ -8,7 +8,7 @@ import { Tooltip } from "../components/Tooltip";
 import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
 import { loadFromJSON, saveAsJSON } from "../data";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { KEYS } from "../keys";
 import { register } from "./register";
 import { supported } from "browser-fs-access";

+ 1 - 1
src/components/Actions.tsx

@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
 import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import {
   canChangeSharpness,
   canHaveArrowheads,

+ 88 - 57
src/components/App.tsx

@@ -1,5 +1,5 @@
 import { Point, simplify } from "points-on-curve";
-import React from "react";
+import React, { useContext } from "react";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import rough from "roughjs/bin/rough";
 import clsx from "clsx";
@@ -54,6 +54,9 @@ import {
   GRID_SIZE,
   LINE_CONFIRM_THRESHOLD,
   MIME_TYPES,
+  MQ_MAX_HEIGHT_LANDSCAPE,
+  MQ_MAX_WIDTH_LANDSCAPE,
+  MQ_MAX_WIDTH_PORTRAIT,
   POINTER_BUTTON,
   SCROLL_TIMEOUT,
   TAP_TWICE_TIMEOUT,
@@ -178,13 +181,15 @@ import {
   viewportCoordsToSceneCoords,
   withBatchedUpdates,
 } from "../utils";
-import { isMobile } from "../is-mobile";
 import ContextMenu, { ContextMenuOption } from "./ContextMenu";
 import LayerUI from "./LayerUI";
 import { Stats } from "./Stats";
 import { Toast } from "./Toast";
 import { actionToggleViewMode } from "../actions/actionToggleViewMode";
 
+export const IsMobileContext = React.createContext(false);
+export const useIsMobile = () => useContext(IsMobileContext);
+
 const { history } = createHistory();
 
 let didTapTwice: boolean = false;
@@ -286,6 +291,9 @@ class App extends React.Component<AppProps, AppState> {
   rc: RoughCanvas | null = null;
   unmounted: boolean = false;
   actionManager: ActionManager;
+  isMobile = false;
+  detachIsMobileMqHandler?: () => void;
+
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
 
   public static defaultProps: Partial<AppProps> = {
@@ -437,60 +445,64 @@ class App extends React.Component<AppProps, AppState> {
       <div
         className={clsx("excalidraw", {
           "excalidraw--view-mode": viewModeEnabled,
+          "excalidraw--mobile": this.isMobile,
         })}
         ref={this.excalidrawContainerRef}
         onDrop={this.handleAppOnDrop}
       >
-        <LayerUI
-          canvas={this.canvas}
-          appState={this.state}
-          setAppState={this.setAppState}
-          actionManager={this.actionManager}
-          elements={this.scene.getElements()}
-          onCollabButtonClick={onCollabButtonClick}
-          onLockToggle={this.toggleLock}
-          onInsertElements={(elements) =>
-            this.addElementsFromPasteOrLibrary(
-              elements,
-              DEFAULT_PASTE_X,
-              DEFAULT_PASTE_Y,
-            )
-          }
-          zenModeEnabled={zenModeEnabled}
-          toggleZenMode={this.toggleZenMode}
-          langCode={getLanguage().code}
-          isCollaborating={this.props.isCollaborating || false}
-          onExportToBackend={onExportToBackend}
-          renderCustomFooter={renderFooter}
-          viewModeEnabled={viewModeEnabled}
-          showExitZenModeBtn={
-            typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
-          }
-          showThemeBtn={
-            typeof this.props?.theme === "undefined" &&
-            this.props.UIOptions.canvasActions.theme
-          }
-          libraryReturnUrl={this.props.libraryReturnUrl}
-          UIOptions={this.props.UIOptions}
-        />
-        <div className="excalidraw-textEditorContainer" />
-        <div className="excalidraw-contextMenuContainer" />
-        {this.state.showStats && (
-          <Stats
+        <IsMobileContext.Provider value={this.isMobile}>
+          <LayerUI
+            canvas={this.canvas}
             appState={this.state}
             setAppState={this.setAppState}
+            actionManager={this.actionManager}
             elements={this.scene.getElements()}
-            onClose={this.toggleStats}
-            renderCustomStats={renderCustomStats}
-          />
-        )}
-        {this.state.toastMessage !== null && (
-          <Toast
-            message={this.state.toastMessage}
-            clearToast={this.clearToast}
+            onCollabButtonClick={onCollabButtonClick}
+            onLockToggle={this.toggleLock}
+            onInsertElements={(elements) =>
+              this.addElementsFromPasteOrLibrary(
+                elements,
+                DEFAULT_PASTE_X,
+                DEFAULT_PASTE_Y,
+              )
+            }
+            zenModeEnabled={zenModeEnabled}
+            toggleZenMode={this.toggleZenMode}
+            langCode={getLanguage().code}
+            isCollaborating={this.props.isCollaborating || false}
+            onExportToBackend={onExportToBackend}
+            renderCustomFooter={renderFooter}
+            viewModeEnabled={viewModeEnabled}
+            showExitZenModeBtn={
+              typeof this.props?.zenModeEnabled === "undefined" &&
+              zenModeEnabled
+            }
+            showThemeBtn={
+              typeof this.props?.theme === "undefined" &&
+              this.props.UIOptions.canvasActions.theme
+            }
+            libraryReturnUrl={this.props.libraryReturnUrl}
+            UIOptions={this.props.UIOptions}
           />
-        )}
-        <main>{this.renderCanvas()}</main>
+          <div className="excalidraw-textEditorContainer" />
+          <div className="excalidraw-contextMenuContainer" />
+          {this.state.showStats && (
+            <Stats
+              appState={this.state}
+              setAppState={this.setAppState}
+              elements={this.scene.getElements()}
+              onClose={this.toggleStats}
+              renderCustomStats={renderCustomStats}
+            />
+          )}
+          {this.state.toastMessage !== null && (
+            <Toast
+              message={this.state.toastMessage}
+              clearToast={this.clearToast}
+            />
+          )}
+          <main>{this.renderCanvas()}</main>
+        </IsMobileContext.Provider>
       </div>
     );
   }
@@ -776,10 +788,29 @@ class App extends React.Component<AppProps, AppState> {
 
     if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
       this.resizeObserver = new ResizeObserver(() => {
+        // compute isMobile state
+        // ---------------------------------------------------------------------
+        const {
+          width,
+          height,
+        } = this.excalidrawContainerRef.current!.getBoundingClientRect();
+        this.isMobile =
+          width < MQ_MAX_WIDTH_PORTRAIT ||
+          (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE);
+        // refresh offsets
+        // ---------------------------------------------------------------------
         this.updateDOMRect();
       });
       this.resizeObserver?.observe(this.excalidrawContainerRef.current);
+    } else if (window.matchMedia) {
+      const mediaQuery = 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 handler = () => (this.isMobile = mediaQuery.matches);
+      mediaQuery.addListener(handler);
+      this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler);
     }
+
     const searchParams = new URLSearchParams(window.location.search.slice(1));
 
     if (searchParams.has("web-share-target")) {
@@ -839,6 +870,8 @@ class App extends React.Component<AppProps, AppState> {
       this.onGestureEnd as any,
       false,
     );
+
+    this.detachIsMobileMqHandler?.();
   }
 
   private addEventListeners() {
@@ -1016,7 +1049,7 @@ class App extends React.Component<AppProps, AppState> {
       },
       {
         renderOptimizations: true,
-        renderScrollbars: !isMobile(),
+        renderScrollbars: !this.isMobile,
       },
     );
     if (scrollBars) {
@@ -3811,8 +3844,6 @@ class App extends React.Component<AppProps, AppState> {
 
     const separator = "separator";
 
-    const _isMobile = isMobile();
-
     const elements = this.scene.getElements();
 
     const options: ContextMenuOption[] = [];
@@ -3849,7 +3880,7 @@ class App extends React.Component<AppProps, AppState> {
 
       ContextMenu.push({
         options: [
-          _isMobile &&
+          this.isMobile &&
             navigator.clipboard && {
               name: "paste",
               perform: (elements, appStates) => {
@@ -3860,7 +3891,7 @@ class App extends React.Component<AppProps, AppState> {
               },
               contextItemLabel: "labels.paste",
             },
-          _isMobile && navigator.clipboard && separator,
+          this.isMobile && navigator.clipboard && separator,
           probablySupportsClipboardBlob &&
             elements.length > 0 &&
             actionCopyAsPng,
@@ -3903,9 +3934,9 @@ class App extends React.Component<AppProps, AppState> {
 
     ContextMenu.push({
       options: [
-        _isMobile && actionCut,
-        _isMobile && navigator.clipboard && actionCopy,
-        _isMobile &&
+        this.isMobile && actionCut,
+        this.isMobile && navigator.clipboard && actionCopy,
+        this.isMobile &&
           navigator.clipboard && {
             name: "paste",
             perform: (elements, appStates) => {
@@ -3916,7 +3947,7 @@ class App extends React.Component<AppProps, AppState> {
             },
             contextItemLabel: "labels.paste",
           },
-        _isMobile && separator,
+        this.isMobile && separator,
         ...options,
         separator,
         actionCopyStyles,

+ 1 - 1
src/components/CollabButton.tsx

@@ -2,7 +2,7 @@ import React from "react";
 import clsx from "clsx";
 import { ToolButton } from "./ToolButton";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { users } from "./icons";
 
 import "./CollabButton.scss";

+ 1 - 1
src/components/ColorPicker.scss

@@ -218,7 +218,7 @@
       left: 2px;
     }
 
-    @media #{$is-mobile-query} {
+    @include isMobile {
       display: none;
     }
   }

+ 1 - 1
src/components/ContextMenu.scss

@@ -76,7 +76,7 @@
     z-index: 1;
   }
 
-  @media #{$is-mobile-query} {
+  @include isMobile {
     .context-menu-option {
       display: block;
 

+ 1 - 1
src/components/Dialog.scss

@@ -31,7 +31,7 @@
     padding: 0 16px 16px;
   }
 
-  @media #{$is-mobile-query} {
+  @include isMobile {
     .Dialog {
       --metric: calc(var(--space-factor) * 4);
       --inset-left: #{"max(var(--metric), var(--sal))"};

+ 1 - 1
src/components/Dialog.tsx

@@ -2,7 +2,7 @@ import clsx from "clsx";
 import React, { useEffect } from "react";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { KEYS } from "../keys";
 import "./Dialog.scss";
 import { back, close } from "./icons";

+ 1 - 1
src/components/ExportDialog.scss

@@ -55,7 +55,7 @@
     }
   }
 
-  @media #{$is-mobile-query} {
+  @include isMobile {
     .ExportDialog {
       display: flex;
       flex-direction: column;

+ 1 - 1
src/components/ExportDialog.tsx

@@ -6,7 +6,7 @@ import { canvasToBlob } from "../data/blob";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { CanvasError } from "../errors";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { exportToCanvas, getExportSize } from "../scene/export";
 import { AppState } from "../types";

+ 1 - 1
src/components/HintViewer.scss

@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
     color: $oc-gray-6;
     font-size: 0.8rem;
 
-    @media #{$is-mobile-query} {
+    @include isMobile {
       position: static;
       padding-right: 2em;
     }

+ 1 - 1
src/components/IconPicker.scss

@@ -111,7 +111,7 @@
     :root[dir="rtl"] & {
       left: 2px;
     }
-    @media #{$is-mobile-query} {
+    @include isMobile {
       display: none;
     }
   }

+ 1 - 1
src/components/LayerUI.tsx

@@ -14,7 +14,7 @@ import { Library } from "../data/library";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { calculateScrollCenter, getSelectedElements } from "../scene";
 import { ExportType } from "../scene/types";
 import {

+ 1 - 1
src/components/LibraryUnit.tsx

@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
 import { close } from "../components/icons";
 import { MIME_TYPES } from "../constants";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { exportToSvg } from "../scene/export";
 import { LibraryItem } from "../types";
 import "./LibraryUnit.scss";

+ 2 - 2
src/components/Modal.scss

@@ -52,7 +52,7 @@
     border-radius: 6px;
     box-sizing: border-box;
 
-    @media #{$is-mobile-query} {
+    @include isMobile {
       max-width: 100%;
       border: 0;
       border-radius: 0;
@@ -82,7 +82,7 @@
     }
   }
 
-  @media #{$is-mobile-query} {
+  @include isMobile {
     .Modal {
       padding: 0;
     }

+ 13 - 1
src/components/Modal.tsx

@@ -1,9 +1,10 @@
 import "./Modal.scss";
 
-import React, { useState, useLayoutEffect } from "react";
+import React, { useState, useLayoutEffect, useRef } from "react";
 import { createPortal } from "react-dom";
 import clsx from "clsx";
 import { KEYS } from "../keys";
+import { useIsMobile } from "../components/App";
 
 export const Modal = (props: {
   className?: string;
@@ -48,6 +49,16 @@ export const Modal = (props: {
 const useBodyRoot = () => {
   const [div, setDiv] = useState<HTMLDivElement | null>(null);
 
+  const isMobile = useIsMobile();
+  const isMobileRef = useRef(isMobile);
+  isMobileRef.current = isMobile;
+
+  useLayoutEffect(() => {
+    if (div) {
+      div.classList.toggle("excalidraw--mobile", isMobile);
+    }
+  }, [div, isMobile]);
+
   useLayoutEffect(() => {
     const isDarkTheme = !!document
       .querySelector(".excalidraw")
@@ -55,6 +66,7 @@ const useBodyRoot = () => {
     const div = document.createElement("div");
 
     div.classList.add("excalidraw", "excalidraw-modal-container");
+    div.classList.toggle("excalidraw--mobile", isMobileRef.current);
 
     if (isDarkTheme) {
       div.classList.add("theme--dark");

+ 2 - 2
src/components/PasteChartDialog.scss

@@ -2,7 +2,7 @@
 
 .excalidraw {
   .PasteChartDialog {
-    @media #{$is-mobile-query} {
+    @include isMobile {
       .Island {
         display: flex;
         flex-direction: column;
@@ -13,7 +13,7 @@
       align-items: center;
       justify-content: space-around;
       flex-wrap: wrap;
-      @media #{$is-mobile-query} {
+      @include isMobile {
         flex-direction: column;
         justify-content: center;
       }

+ 1 - 1
src/components/Stats.tsx

@@ -2,7 +2,7 @@ import React from "react";
 import { getCommonBounds } from "../element/bounds";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
-import { useIsMobile } from "../is-mobile";
+import { useIsMobile } from "../components/App";
 import { getTargetElements } from "../scene";
 import { AppState, ExcalidrawProps } from "../types";
 import { close } from "./icons";

+ 1 - 1
src/components/ToolIcon.scss

@@ -193,7 +193,7 @@
     margin-left: 5px;
     margin-top: 1px;
 
-    @media #{$is-mobile-query} {
+    @include isMobile {
       display: none;
     }
   }

+ 4 - 0
src/constants.ts

@@ -137,3 +137,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
     theme: true,
   },
 };
+
+export const MQ_MAX_WIDTH_PORTRAIT = 730;
+export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
+export const MQ_MAX_HEIGHT_LANDSCAPE = 500;

+ 1 - 1
src/css/styles.scss

@@ -480,7 +480,7 @@
     }
   }
 
-  @media #{$is-mobile-query} {
+  @include isMobile {
     aside {
       display: none;
     }

+ 6 - 3
src/css/variables.module.scss

@@ -1,10 +1,13 @@
 @import "open-color/open-color.scss";
 
-// keep up to date with is-mobile.tsx
-$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
+@mixin isMobile() {
+  @at-root .excalidraw--mobile#{&} {
+    @content;
+  }
+}
+
 $theme-filter: "invert(93%) hue-rotate(180deg)";
 
 :export {
-  isMobileQuery: unquote($is-mobile-query);
   themeFilter: unquote($theme-filter);
 }

+ 3 - 3
src/excalidraw-app/collab/RoomDialog.scss

@@ -32,13 +32,13 @@
     display: flex;
     align-items: center;
     justify-content: center;
-    @media #{$is-mobile-query} {
+    @include isMobile {
       flex-direction: column;
       align-items: stretch;
     }
   }
 
-  @media #{$is-mobile-query} {
+  @include isMobile {
     .RoomDialog-usernameLabel {
       font-weight: bold;
     }
@@ -51,7 +51,7 @@
     min-width: 0;
     flex: 1 1 auto;
     margin-inline-start: 1em;
-    @media #{$is-mobile-query} {
+    @include isMobile {
       margin-top: 0.5em;
       margin-inline-start: 0;
     }

+ 0 - 37
src/is-mobile.tsx

@@ -1,37 +0,0 @@
-import React, { useState, useEffect, useRef, useContext } from "react";
-import variables from "./css/variables.module.scss";
-
-const context = React.createContext(false);
-
-const getIsMobileMatcher = () => {
-  return window.matchMedia
-    ? window.matchMedia(variables.isMobileQuery)
-    : (({
-        matches: false,
-        addListener: () => {},
-        removeListener: () => {},
-      } as any) as MediaQueryList);
-};
-
-export const IsMobileProvider = ({
-  children,
-}: {
-  children: React.ReactNode;
-}) => {
-  const query = useRef<MediaQueryList>();
-  if (!query.current) {
-    query.current = getIsMobileMatcher();
-  }
-  const [isMobile, setMobile] = useState(query.current.matches);
-
-  useEffect(() => {
-    const handler = () => setMobile(query.current!.matches);
-    query.current!.addListener(handler);
-    return () => query.current!.removeListener(handler);
-  }, []);
-
-  return <context.Provider value={isMobile}>{children}</context.Provider>;
-};
-
-export const isMobile = () => getIsMobileMatcher().matches;
-export const useIsMobile = () => useContext(context);

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

@@ -11,6 +11,14 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 -->
 
+## Unreleased
+
+## Excalidraw Library
+
+### Features
+
+- App now breaks into mobile view using the component dimensions, not viewport dimensions. This fixes a case where the app would break sooner than necessary when the component's size is smaller than viewport [#3414](https://github.com/excalidraw/excalidraw/pull/3414).
+
 ## 0.6.0 (2021-04-04)
 
 ## Excalidraw API

+ 19 - 22
src/packages/excalidraw/index.tsx

@@ -8,7 +8,6 @@ import "../../css/app.scss";
 import "../../css/styles.scss";
 
 import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
-import { IsMobileProvider } from "../../is-mobile";
 import { defaultLang } from "../../i18n";
 import { DEFAULT_UI_OPTIONS } from "../../constants";
 
@@ -61,27 +60,25 @@ const Excalidraw = (props: ExcalidrawProps) => {
 
   return (
     <InitializeApp langCode={langCode}>
-      <IsMobileProvider>
-        <App
-          onChange={onChange}
-          initialData={initialData}
-          excalidrawRef={excalidrawRef}
-          onCollabButtonClick={onCollabButtonClick}
-          isCollaborating={isCollaborating}
-          onPointerUpdate={onPointerUpdate}
-          onExportToBackend={onExportToBackend}
-          renderFooter={renderFooter}
-          langCode={langCode}
-          viewModeEnabled={viewModeEnabled}
-          zenModeEnabled={zenModeEnabled}
-          gridModeEnabled={gridModeEnabled}
-          libraryReturnUrl={libraryReturnUrl}
-          theme={theme}
-          name={name}
-          renderCustomStats={renderCustomStats}
-          UIOptions={UIOptions}
-        />
-      </IsMobileProvider>
+      <App
+        onChange={onChange}
+        initialData={initialData}
+        excalidrawRef={excalidrawRef}
+        onCollabButtonClick={onCollabButtonClick}
+        isCollaborating={isCollaborating}
+        onPointerUpdate={onPointerUpdate}
+        onExportToBackend={onExportToBackend}
+        renderFooter={renderFooter}
+        langCode={langCode}
+        viewModeEnabled={viewModeEnabled}
+        zenModeEnabled={zenModeEnabled}
+        gridModeEnabled={gridModeEnabled}
+        libraryReturnUrl={libraryReturnUrl}
+        theme={theme}
+        name={name}
+        renderCustomStats={renderCustomStats}
+        UIOptions={UIOptions}
+      />
     </InitializeApp>
   );
 };