From 09dfd16b171d834bbfec89712de164d895244dc4 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Thu, 8 Apr 2021 19:54:50 +0200 Subject: [PATCH] feat: use component dimensions to break to mobile (#3414) Co-authored-by: Jed Fox --- src/actions/actionCanvas.tsx | 2 +- src/actions/actionExport.tsx | 2 +- src/components/Actions.tsx | 2 +- src/components/App.tsx | 145 +++++++++++++--------- src/components/CollabButton.tsx | 2 +- src/components/ColorPicker.scss | 2 +- src/components/ContextMenu.scss | 2 +- src/components/Dialog.scss | 2 +- src/components/Dialog.tsx | 2 +- src/components/ExportDialog.scss | 2 +- src/components/ExportDialog.tsx | 2 +- src/components/HintViewer.scss | 2 +- src/components/IconPicker.scss | 2 +- src/components/LayerUI.tsx | 2 +- src/components/LibraryUnit.tsx | 2 +- src/components/Modal.scss | 4 +- src/components/Modal.tsx | 14 ++- src/components/PasteChartDialog.scss | 4 +- src/components/Stats.tsx | 2 +- src/components/ToolIcon.scss | 2 +- src/constants.ts | 4 + src/css/styles.scss | 2 +- src/css/variables.module.scss | 9 +- src/excalidraw-app/collab/RoomDialog.scss | 6 +- src/is-mobile.tsx | 37 ------ src/packages/excalidraw/CHANGELOG.md | 8 ++ src/packages/excalidraw/index.tsx | 41 +++--- 27 files changed, 162 insertions(+), 144 deletions(-) delete mode 100644 src/is-mobile.tsx diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 3a908ebe..5257cb31 100644 --- a/src/actions/actionCanvas.tsx +++ b/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"; diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 00220149..46ed8baa 100644 --- a/src/actions/actionExport.tsx +++ b/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"; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 3aac0133..6835b920 100644 --- a/src/components/Actions.tsx +++ b/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, diff --git a/src/components/App.tsx b/src/components/App.tsx index a2118d91..6664ebfb 100644 --- a/src/components/App.tsx +++ b/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 { rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; + isMobile = false; + detachIsMobileMqHandler?: () => void; + private excalidrawContainerRef = React.createRef(); public static defaultProps: Partial = { @@ -437,60 +445,64 @@ class App extends React.Component {
- - 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} - /> -
-
- {this.state.showStats && ( - + + 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} /> - )} - {this.state.toastMessage !== null && ( - - )} -
{this.renderCanvas()}
+
+
+ {this.state.showStats && ( + + )} + {this.state.toastMessage !== null && ( + + )} +
{this.renderCanvas()}
+
); } @@ -776,10 +788,29 @@ class App extends React.Component { 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 { this.onGestureEnd as any, false, ); + + this.detachIsMobileMqHandler?.(); } private addEventListeners() { @@ -1016,7 +1049,7 @@ class App extends React.Component { }, { renderOptimizations: true, - renderScrollbars: !isMobile(), + renderScrollbars: !this.isMobile, }, ); if (scrollBars) { @@ -3811,8 +3844,6 @@ class App extends React.Component { const separator = "separator"; - const _isMobile = isMobile(); - const elements = this.scene.getElements(); const options: ContextMenuOption[] = []; @@ -3849,7 +3880,7 @@ class App extends React.Component { ContextMenu.push({ options: [ - _isMobile && + this.isMobile && navigator.clipboard && { name: "paste", perform: (elements, appStates) => { @@ -3860,7 +3891,7 @@ class App extends React.Component { }, 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 { 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 { }, contextItemLabel: "labels.paste", }, - _isMobile && separator, + this.isMobile && separator, ...options, separator, actionCopyStyles, diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx index 8b6feae0..ceb86c19 100644 --- a/src/components/CollabButton.tsx +++ b/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"; diff --git a/src/components/ColorPicker.scss b/src/components/ColorPicker.scss index fb960c9f..0f672b53 100644 --- a/src/components/ColorPicker.scss +++ b/src/components/ColorPicker.scss @@ -218,7 +218,7 @@ left: 2px; } - @media #{$is-mobile-query} { + @include isMobile { display: none; } } diff --git a/src/components/ContextMenu.scss b/src/components/ContextMenu.scss index d95d9fe2..df8db7fb 100644 --- a/src/components/ContextMenu.scss +++ b/src/components/ContextMenu.scss @@ -76,7 +76,7 @@ z-index: 1; } - @media #{$is-mobile-query} { + @include isMobile { .context-menu-option { display: block; diff --git a/src/components/Dialog.scss b/src/components/Dialog.scss index 2cb38660..435c56b6 100644 --- a/src/components/Dialog.scss +++ b/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))"}; diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index f10a287d..26467517 100644 --- a/src/components/Dialog.tsx +++ b/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"; diff --git a/src/components/ExportDialog.scss b/src/components/ExportDialog.scss index b02aa530..541c17e6 100644 --- a/src/components/ExportDialog.scss +++ b/src/components/ExportDialog.scss @@ -55,7 +55,7 @@ } } - @media #{$is-mobile-query} { + @include isMobile { .ExportDialog { display: flex; flex-direction: column; diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 1a225a05..f09ed99d 100644 --- a/src/components/ExportDialog.tsx +++ b/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"; diff --git a/src/components/HintViewer.scss b/src/components/HintViewer.scss index bddafaf5..2e3a15bf 100644 --- a/src/components/HintViewer.scss +++ b/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; } diff --git a/src/components/IconPicker.scss b/src/components/IconPicker.scss index b83fb7e8..0bf0b1bc 100644 --- a/src/components/IconPicker.scss +++ b/src/components/IconPicker.scss @@ -111,7 +111,7 @@ :root[dir="rtl"] & { left: 2px; } - @media #{$is-mobile-query} { + @include isMobile { display: none; } } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 27c7f5aa..304bb048 100644 --- a/src/components/LayerUI.tsx +++ b/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 { diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 2698def0..8e0bd058 100644 --- a/src/components/LibraryUnit.tsx +++ b/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"; diff --git a/src/components/Modal.scss b/src/components/Modal.scss index a26e337b..31705251 100644 --- a/src/components/Modal.scss +++ b/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; } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 03421b3b..fd5fcb03 100644 --- a/src/components/Modal.tsx +++ b/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(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"); diff --git a/src/components/PasteChartDialog.scss b/src/components/PasteChartDialog.scss index dc76306a..2bc49560 100644 --- a/src/components/PasteChartDialog.scss +++ b/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; } diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 3113aa10..6df7f850 100644 --- a/src/components/Stats.tsx +++ b/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"; diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss index df07ca02..3d3f37d1 100644 --- a/src/components/ToolIcon.scss +++ b/src/components/ToolIcon.scss @@ -193,7 +193,7 @@ margin-left: 5px; margin-top: 1px; - @media #{$is-mobile-query} { + @include isMobile { display: none; } } diff --git a/src/constants.ts b/src/constants.ts index 935c7b49..ac246af9 100644 --- a/src/constants.ts +++ b/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; diff --git a/src/css/styles.scss b/src/css/styles.scss index 5592eb25..164c9423 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -480,7 +480,7 @@ } } - @media #{$is-mobile-query} { + @include isMobile { aside { display: none; } diff --git a/src/css/variables.module.scss b/src/css/variables.module.scss index f60b0d9d..0d2c37f9 100644 --- a/src/css/variables.module.scss +++ b/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); } diff --git a/src/excalidraw-app/collab/RoomDialog.scss b/src/excalidraw-app/collab/RoomDialog.scss index 2cf0425a..574f4840 100644 --- a/src/excalidraw-app/collab/RoomDialog.scss +++ b/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; } diff --git a/src/is-mobile.tsx b/src/is-mobile.tsx deleted file mode 100644 index a64942f5..00000000 --- a/src/is-mobile.tsx +++ /dev/null @@ -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(); - 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 {children}; -}; - -export const isMobile = () => getIsMobileMatcher().matches; -export const useIsMobile = () => useContext(context); diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index f36b428f..23751024 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/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 diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 2a9f041a..7f1e7f37 100644 --- a/src/packages/excalidraw/index.tsx +++ b/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 ( - - - + ); };