feat: use component dimensions to break to mobile ()

Co-authored-by: Jed Fox <git@jedfox.com>
This commit is contained in:
David Luzar
2021-04-08 19:54:50 +02:00
committed by GitHub
parent 016e69b9f2
commit 09dfd16b17
27 changed files with 162 additions and 144 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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);

@ -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. 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) ## 0.6.0 (2021-04-04)
## Excalidraw API ## Excalidraw API

@ -8,7 +8,6 @@ import "../../css/app.scss";
import "../../css/styles.scss"; import "../../css/styles.scss";
import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types"; import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { IsMobileProvider } from "../../is-mobile";
import { defaultLang } from "../../i18n"; import { defaultLang } from "../../i18n";
import { DEFAULT_UI_OPTIONS } from "../../constants"; import { DEFAULT_UI_OPTIONS } from "../../constants";
@ -61,7 +60,6 @@ const Excalidraw = (props: ExcalidrawProps) => {
return ( return (
<InitializeApp langCode={langCode}> <InitializeApp langCode={langCode}>
<IsMobileProvider>
<App <App
onChange={onChange} onChange={onChange}
initialData={initialData} initialData={initialData}
@ -81,7 +79,6 @@ const Excalidraw = (props: ExcalidrawProps) => {
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />
</IsMobileProvider>
</InitializeApp> </InitializeApp>
); );
}; };