Browse Source

Initial support for mobile devices (#787)

* Initial support for mobile devices

No editing yet, but UI looks nice and you can open the canvas menu

* Add support for editing shape color, etc

* Allow the mobile menus to cover the shape selector

* Hopefully fix test error

* Fix touch on canvas

* Fix safe area handling & remove unused Island
Jed Fox 5 years ago
parent
commit
7a7a73b78d

+ 1 - 1
public/index.html

@@ -5,7 +5,7 @@
     <title>Excalidraw</title>
     <meta
       name="viewport"
-      content="width=device-width, initial-scale=1, shrink-to-fit=no"
+      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
     />
     <meta name="theme-color" content="#000000" />
     <!-- prettier-ignore -->

+ 2 - 0
src/actions/actionCanvas.tsx

@@ -7,6 +7,7 @@ import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import { getNormalizedZoom } from "../scene";
 import { KEYS } from "../keys";
+import useIsMobile from "../is-mobile";
 
 export const actionChangeViewBackgroundColor: Action = {
   name: "changeViewBackgroundColor",
@@ -43,6 +44,7 @@ export const actionClearCanvas: Action = {
       icon={trash}
       title={t("buttons.clearReset")}
       aria-label={t("buttons.clearReset")}
+      showAriaLabel={useIsMobile()}
       onClick={() => {
         if (window.confirm(t("alerts.clearReset"))) {
           // TODO: Defined globally, since file handles aren't yet serializable.

+ 3 - 0
src/actions/actionExport.tsx

@@ -5,6 +5,7 @@ import { saveAsJSON, loadFromJSON } from "../scene";
 import { load, save } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
+import useIsMobile from "../is-mobile";
 
 export const actionChangeProjectName: Action = {
   name: "changeProjectName",
@@ -51,6 +52,7 @@ export const actionSaveScene: Action = {
       icon={save}
       title={t("buttons.save")}
       aria-label={t("buttons.save")}
+      showAriaLabel={useIsMobile()}
       onClick={() => updateData(null)}
     />
   ),
@@ -71,6 +73,7 @@ export const actionLoadScene: Action = {
       icon={load}
       title={t("buttons.load")}
       aria-label={t("buttons.load")}
+      showAriaLabel={useIsMobile()}
       onClick={() => {
         loadFromJSON()
           .then(({ elements, appState }) => {

+ 1 - 0
src/appState.ts

@@ -29,6 +29,7 @@ export function getDefaultAppState(): AppState {
     isResizing: false,
     selectionElement: null,
     zoom: 1,
+    openedMenu: null,
   };
 }
 

+ 2 - 0
src/components/ColorPicker.css

@@ -98,7 +98,9 @@
   box-sizing: content-box;
   border-radius: 0px 4px 4px 0px;
   float: left;
+  padding: 1px;
   padding-left: 0.5em;
+  appearance: none;
 }
 
 .color-picker-label-swatch {

+ 2 - 0
src/components/ExportDialog.tsx

@@ -17,6 +17,7 @@ import { KEYS } from "../keys";
 
 import { probablySupportsClipboardBlob } from "../clipboard";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
+import useIsMobile from "../is-mobile";
 
 const scales = [1, 2, 3];
 const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
@@ -233,6 +234,7 @@ export function ExportDialog({
         icon={exportFile}
         type="button"
         aria-label={t("buttons.export")}
+        showAriaLabel={useIsMobile()}
         title={t("buttons.export")}
         ref={triggerButton}
       />

+ 4 - 0
src/components/ToolButton.tsx

@@ -14,6 +14,7 @@ type ToolButtonBaseProps = {
   id?: string;
   size?: ToolIconSize;
   keyBindingLabel?: string;
+  showAriaLabel?: boolean;
 };
 
 type ToolButtonProps =
@@ -48,6 +49,9 @@ export const ToolButton = React.forwardRef(function(
         <div className="ToolIcon__icon" aria-hidden="true">
           {props.icon || props.label}
         </div>
+        {props.showAriaLabel && (
+          <div className="ToolIcon__label">{props["aria-label"]}</div>
+        )}
       </button>
     );
   }

+ 15 - 11
src/components/ToolIcon.scss

@@ -1,13 +1,13 @@
 .ToolIcon {
-  display: inline-block;
+  display: inline-flex;
+  align-items: center;
   position: relative;
   font-family: Cascadia;
   cursor: pointer;
+  background-color: #e9ecef;
 }
 
 .ToolIcon__icon {
-  background-color: #e9ecef;
-
   width: 2.5rem;
   height: 2.5rem;
 
@@ -23,6 +23,10 @@
   }
 }
 
+.ToolIcon__label {
+  font-family: var(--ui-font);
+}
+
 .ToolIcon_size_s .ToolIcon__icon {
   width: 1.4rem;
   height: 1.4rem;
@@ -35,13 +39,13 @@
   margin: 0;
   font-size: inherit;
 
-  &:hover .ToolIcon__icon {
+  &:hover {
     background-color: #e9ecef;
   }
-  &:active .ToolIcon__icon {
+  &:active {
     background-color: #ced4da;
   }
-  &:focus .ToolIcon__icon {
+  &:focus {
     box-shadow: 0 0 0 2px #a5d8ff;
   }
 }
@@ -70,19 +74,19 @@
   align-items: center;
   justify-content: center;
   margin-left: 0.1rem;
+  background-color: transparent;
 
   .ToolIcon__icon {
-    background-color: transparent;
     width: 2rem;
     height: 2em;
   }
-  &:hover .ToolIcon__icon {
+  &:hover {
     background-color: transparent;
   }
-  &:active .ToolIcon__icon {
+  &:active {
     background-color: transparent;
   }
-  &:focus .ToolIcon__icon {
+  &:focus {
     box-shadow: none;
   }
 }
@@ -93,6 +97,6 @@
   right: 3px;
   font-size: 0.5em;
   color: #adb5bd; // OC GRAY 5
-  font-family: Arial, Helvetica, sans-serif;
+  font-family: var(--ui-font);
   user-select: none;
 }

+ 237 - 111
src/index.tsx

@@ -104,6 +104,7 @@ import { LanguageList } from "./components/LanguageList";
 import { Point } from "roughjs/bin/geometry";
 import { t, languages, setLanguage, getLanguage } from "./i18n";
 import { HintViewer } from "./components/HintViewer";
+import useIsMobile, { IsMobileProvider } from "./is-mobile";
 
 import { copyToAppClipboard, getClipboardContent } from "./clipboard";
 import { normalizeScroll } from "./scene/data";
@@ -135,6 +136,18 @@ const MOUSE_BUTTON = {
   SECONDARY: 2,
 };
 
+// Block pinch-zooming on iOS outside of the content area
+document.addEventListener(
+  "touchmove",
+  function(event) {
+    // @ts-ignore
+    if (event.scale !== 1) {
+      event.preventDefault();
+    }
+  },
+  { passive: false },
+);
+
 let lastMouseUp: ((e: any) => void) | null = null;
 
 export function viewportCoordsToSceneCoords(
@@ -211,64 +224,58 @@ const LayerUI = React.memo(
     language,
     setElements,
   }: LayerUIProps) => {
-    function renderCanvasActions() {
+    const isMobile = useIsMobile();
+
+    function renderExportDialog() {
       return (
-        <Stack.Col gap={4}>
-          <Stack.Row justifyContent={"space-between"}>
-            {actionManager.renderAction("loadScene")}
-            {actionManager.renderAction("saveScene")}
-            <ExportDialog
-              elements={elements}
-              appState={appState}
-              actionManager={actionManager}
-              onExportToPng={(exportedElements, scale) => {
-                if (canvas) {
-                  exportCanvas("png", exportedElements, canvas, {
-                    exportBackground: appState.exportBackground,
-                    name: appState.name,
-                    viewBackgroundColor: appState.viewBackgroundColor,
-                    scale,
-                  });
-                }
-              }}
-              onExportToSvg={(exportedElements, scale) => {
-                if (canvas) {
-                  exportCanvas("svg", exportedElements, canvas, {
-                    exportBackground: appState.exportBackground,
-                    name: appState.name,
-                    viewBackgroundColor: appState.viewBackgroundColor,
-                    scale,
-                  });
-                }
-              }}
-              onExportToClipboard={(exportedElements, scale) => {
-                if (canvas) {
-                  exportCanvas("clipboard", exportedElements, canvas, {
-                    exportBackground: appState.exportBackground,
-                    name: appState.name,
-                    viewBackgroundColor: appState.viewBackgroundColor,
-                    scale,
-                  });
-                }
-              }}
-              onExportToBackend={exportedElements => {
-                if (canvas) {
-                  exportCanvas(
-                    "backend",
-                    exportedElements.map(element => ({
-                      ...element,
-                      isSelected: false,
-                    })),
-                    canvas,
-                    appState,
-                  );
-                }
-              }}
-            />
-            {actionManager.renderAction("clearCanvas")}
-          </Stack.Row>
-          {actionManager.renderAction("changeViewBackgroundColor")}
-        </Stack.Col>
+        <ExportDialog
+          elements={elements}
+          appState={appState}
+          actionManager={actionManager}
+          onExportToPng={(exportedElements, scale) => {
+            if (canvas) {
+              exportCanvas("png", exportedElements, canvas, {
+                exportBackground: appState.exportBackground,
+                name: appState.name,
+                viewBackgroundColor: appState.viewBackgroundColor,
+                scale,
+              });
+            }
+          }}
+          onExportToSvg={(exportedElements, scale) => {
+            if (canvas) {
+              exportCanvas("svg", exportedElements, canvas, {
+                exportBackground: appState.exportBackground,
+                name: appState.name,
+                viewBackgroundColor: appState.viewBackgroundColor,
+                scale,
+              });
+            }
+          }}
+          onExportToClipboard={(exportedElements, scale) => {
+            if (canvas) {
+              exportCanvas("clipboard", exportedElements, canvas, {
+                exportBackground: appState.exportBackground,
+                name: appState.name,
+                viewBackgroundColor: appState.viewBackgroundColor,
+                scale,
+              });
+            }
+          }}
+          onExportToBackend={exportedElements => {
+            if (canvas) {
+              exportCanvas(
+                "backend",
+                exportedElements.map(element => ({
+                  ...element,
+                  isSelected: false,
+                })),
+                canvas,
+                appState,
+              );
+            }
+          }}
+        />
       );
     }
 
@@ -284,51 +291,49 @@ const LayerUI = React.memo(
       }
 
       return (
-        <Island padding={4}>
-          <div className="panelColumn">
-            {actionManager.renderAction("changeStrokeColor")}
-            {(hasBackground(elementType) ||
-              targetElements.some(element => hasBackground(element.type))) && (
-              <>
-                {actionManager.renderAction("changeBackgroundColor")}
-
-                {actionManager.renderAction("changeFillStyle")}
-              </>
-            )}
+        <div className="panelColumn">
+          {actionManager.renderAction("changeStrokeColor")}
+          {(hasBackground(elementType) ||
+            targetElements.some(element => hasBackground(element.type))) && (
+            <>
+              {actionManager.renderAction("changeBackgroundColor")}
+
+              {actionManager.renderAction("changeFillStyle")}
+            </>
+          )}
 
-            {(hasStroke(elementType) ||
-              targetElements.some(element => hasStroke(element.type))) && (
-              <>
-                {actionManager.renderAction("changeStrokeWidth")}
+          {(hasStroke(elementType) ||
+            targetElements.some(element => hasStroke(element.type))) && (
+            <>
+              {actionManager.renderAction("changeStrokeWidth")}
 
-                {actionManager.renderAction("changeSloppiness")}
-              </>
-            )}
+              {actionManager.renderAction("changeSloppiness")}
+            </>
+          )}
 
-            {(hasText(elementType) ||
-              targetElements.some(element => hasText(element.type))) && (
-              <>
-                {actionManager.renderAction("changeFontSize")}
+          {(hasText(elementType) ||
+            targetElements.some(element => hasText(element.type))) && (
+            <>
+              {actionManager.renderAction("changeFontSize")}
 
-                {actionManager.renderAction("changeFontFamily")}
-              </>
-            )}
+              {actionManager.renderAction("changeFontFamily")}
+            </>
+          )}
 
-            {actionManager.renderAction("changeOpacity")}
+          {actionManager.renderAction("changeOpacity")}
 
-            <fieldset>
-              <legend>{t("labels.layers")}</legend>
-              <div className="buttonList">
-                {actionManager.renderAction("sendToBack")}
-                {actionManager.renderAction("sendBackward")}
-                {actionManager.renderAction("bringToFront")}
-                {actionManager.renderAction("bringForward")}
-              </div>
-            </fieldset>
+          <fieldset>
+            <legend>{t("labels.layers")}</legend>
+            <div className="buttonList">
+              {actionManager.renderAction("sendToBack")}
+              {actionManager.renderAction("sendBackward")}
+              {actionManager.renderAction("bringToFront")}
+              {actionManager.renderAction("bringForward")}
+            </div>
+          </fieldset>
 
-            {actionManager.renderAction("deleteSelectedElements")}
-          </div>
-        </Island>
+          {actionManager.renderAction("deleteSelectedElements")}
+        </div>
       );
     }
 
@@ -378,7 +383,125 @@ const LayerUI = React.memo(
       );
     }
 
-    return (
+    const lockButton = (
+      <LockIcon
+        checked={appState.elementLocked}
+        onChange={() => {
+          setAppState({
+            elementLocked: !appState.elementLocked,
+            elementType: appState.elementLocked
+              ? "selection"
+              : appState.elementType,
+          });
+        }}
+        title={t("toolBar.lock")}
+      />
+    );
+
+    return isMobile ? (
+      <>
+        {appState.openedMenu === "canvas" ? (
+          <section
+            className="App-mobile-menu"
+            aria-labelledby="canvas-actions-title"
+          >
+            <h2 className="visually-hidden" id="canvas-actions-title">
+              {t("headings.canvasActions")}
+            </h2>
+            <div className="App-mobile-menu-scroller">
+              <Stack.Col gap={4}>
+                {actionManager.renderAction("loadScene")}
+                {actionManager.renderAction("saveScene")}
+                {renderExportDialog()}
+                {actionManager.renderAction("clearCanvas")}
+                {actionManager.renderAction("changeViewBackgroundColor")}
+              </Stack.Col>
+            </div>
+          </section>
+        ) : appState.openedMenu === "shape" ? (
+          <section
+            className="App-mobile-menu"
+            aria-labelledby="selected-shape-title"
+          >
+            <h2 className="visually-hidden" id="selected-shape-title">
+              {t("headings.selectedShapeActions")}
+            </h2>
+            <div className="App-mobile-menu-scroller">
+              {renderSelectedShapeActions(elements)}
+            </div>
+          </section>
+        ) : null}
+        <FixedSideContainer side="top">
+          <section aria-labelledby="shapes-title">
+            <Stack.Col gap={4} align="center">
+              <Stack.Row gap={1}>
+                <Island padding={1}>
+                  <h2 className="visually-hidden" id="shapes-title">
+                    {t("headings.shapes")}
+                  </h2>
+                  <Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
+                </Island>
+              </Stack.Row>
+            </Stack.Col>
+          </section>
+        </FixedSideContainer>
+        <footer className="App-toolbar">
+          <div className="App-toolbar-content">
+            <ToolButton
+              type="button"
+              icon={
+                <span style={{ fontSize: "2em", marginTop: "-0.15em" }}>☰</span>
+              }
+              aria-label={t("buttons.menu")}
+              onClick={() =>
+                setAppState(({ openedMenu }: any) => ({
+                  openedMenu: openedMenu === "canvas" ? null : "canvas",
+                }))
+              }
+            />
+            {lockButton}
+            <div
+              style={{
+                visibility: isSomeElementSelected(elements)
+                  ? "visible"
+                  : "hidden",
+              }}
+            >
+              <ToolButton
+                type="button"
+                icon={
+                  <span style={{ fontSize: "2em", marginTop: "-0.15em" }}>
+                    ✎
+                  </span>
+                }
+                aria-label={t("buttons.menu")}
+                onClick={() =>
+                  setAppState(({ openedMenu }: any) => ({
+                    openedMenu: openedMenu === "shape" ? null : "shape",
+                  }))
+                }
+              />
+            </div>
+            <HintViewer
+              elementType={appState.elementType}
+              multiMode={appState.multiElement !== null}
+              isResizing={appState.isResizing}
+              elements={elements}
+            />
+            {appState.scrolledOutside && (
+              <button
+                className="scroll-back-to-content"
+                onClick={() => {
+                  setAppState({ ...calculateScrollCenter(elements) });
+                }}
+              >
+                {t("buttons.scrollBackToContent")}
+              </button>
+            )}
+          </div>
+        </footer>
+      </>
+    ) : (
       <>
         <FixedSideContainer side="top">
           <div className="App-menu App-menu_top">
@@ -390,7 +513,17 @@ const LayerUI = React.memo(
                 <h2 className="visually-hidden" id="canvas-actions-title">
                   {t("headings.canvasActions")}
                 </h2>
-                <Island padding={4}>{renderCanvasActions()}</Island>
+                <Island padding={4}>
+                  <Stack.Col gap={4}>
+                    <Stack.Row justifyContent={"space-between"}>
+                      {actionManager.renderAction("loadScene")}
+                      {actionManager.renderAction("saveScene")}
+                      {renderExportDialog()}
+                      {actionManager.renderAction("clearCanvas")}
+                    </Stack.Row>
+                    {actionManager.renderAction("changeViewBackgroundColor")}
+                  </Stack.Col>
+                </Island>
               </section>
               <section
                 className="App-right-menu"
@@ -399,7 +532,9 @@ const LayerUI = React.memo(
                 <h2 className="visually-hidden" id="selected-shape-title">
                   {t("headings.selectedShapeActions")}
                 </h2>
-                {renderSelectedShapeActions(elements)}
+                <Island padding={4}>
+                  {renderSelectedShapeActions(elements)}
+                </Island>
               </section>
             </Stack.Col>
             <section aria-labelledby="shapes-title">
@@ -411,18 +546,7 @@ const LayerUI = React.memo(
                     </h2>
                     <Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
                   </Island>
-                  <LockIcon
-                    checked={appState.elementLocked}
-                    onChange={() => {
-                      setAppState({
-                        elementLocked: !appState.elementLocked,
-                        elementType: appState.elementLocked
-                          ? "selection"
-                          : appState.elementType,
-                      });
-                    }}
-                    title={t("toolBar.lock")}
-                  />
+                  {lockButton}
                 </Stack.Row>
               </Stack.Col>
             </section>
@@ -2204,7 +2328,9 @@ class TopErrorBoundary extends React.Component {
 
 ReactDOM.render(
   <TopErrorBoundary>
-    <App />
+    <IsMobileProvider>
+      <App />
+    </IsMobileProvider>
   </TopErrorBoundary>,
   rootElement,
 );

+ 25 - 0
src/is-mobile.tsx

@@ -0,0 +1,25 @@
+import React, { useState, useEffect, useRef, useContext } from "react";
+
+const context = React.createContext(false);
+
+export function IsMobileProvider({ children }: { children: React.ReactNode }) {
+  const query = useRef<MediaQueryList>();
+  if (!query.current) {
+    query.current = window.matchMedia(
+      "(max-width: 600px), (max-height: 500px)",
+    );
+  }
+  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 default function useIsMobile() {
+  return useContext(context);
+}

+ 3 - 2
src/locales/en.json

@@ -43,7 +43,7 @@
     "layers": "Layers"
   },
   "buttons": {
-    "clearReset": "Clear the canvas & reset background color",
+    "clearReset": "Reset the canvas",
     "export": "Export",
     "exportToPng": "Export to PNG",
     "exportToSvg": "Export to SVG",
@@ -55,7 +55,8 @@
     "selectLanguage": "Select Language",
     "scrollBackToContent": "Scroll back to content",
     "zoomIn": "Zoom in",
-    "zoomOut": "Zoom out"
+    "zoomOut": "Zoom out",
+    "menu": "Menu"
   },
   "alerts": {
     "clearReset": "This will clear the whole canvas. Are you sure?",

+ 63 - 1
src/styles.scss

@@ -2,11 +2,16 @@
 
 body {
   margin: 0;
-  font-family: Arial, Helvetica, sans-serif;
+  --ui-font: Arial, Helvetica, sans-serif;
+  font-family: var(--ui-font);
   color: var(--text-color-primary);
+  -webkit-text-size-adjust: 100%;
 }
 
 canvas {
+  touch-action: none;
+  user-select: none;
+
   // following props improve blurriness at certain devicePixelRatios.
   // AFAIK it doesn't affect export (in fact, export seems sharp either way).
 
@@ -24,6 +29,11 @@ canvas {
   right: 0;
 }
 
+.panelRow {
+  display: flex;
+  justify-content: space-between;
+}
+
 .panelColumn {
   display: flex;
   flex-direction: column;
@@ -91,6 +101,7 @@ input:focus {
 
 button,
 .buttonList label {
+  user-select: none;
   background-color: #e9ecef;
   border: 0;
   border-radius: 4px;
@@ -128,6 +139,47 @@ button,
   }
 }
 
+.App-toolbar {
+  padding: var(--spacing);
+  padding-bottom: #{"max(var(--spacing), env(safe-area-inset-bottom))"};
+  padding-left: #{"max(var(--spacing), env(safe-area-inset-left))"};
+  padding-right: #{"max(var(--spacing), env(safe-area-inset-right))"};
+  width: 100%;
+  box-sizing: border-box;
+  overflow: auto;
+  position: absolute;
+  bottom: 0;
+}
+.App-toolbar-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.App-toolbar,
+.App-mobile-menu {
+  --spacing: 0.5rem;
+  background: #fcfcfc;
+  border-top: 1px solid #ccc;
+}
+.App-mobile-menu {
+  --bottom: calc(3rem - 1px + max(var(--spacing), env(safe-area-inset-bottom)));
+  display: grid;
+  position: fixed;
+  width: 100%;
+  bottom: var(--bottom);
+  z-index: 4;
+  max-height: calc(100% - var(--bottom));
+  overflow-y: scroll;
+}
+.App-mobile-menu .App-mobile-menu-scroller {
+  background: #fcfcfc;
+  box-shadow: none;
+  --padding: calc(4 * var(--space-factor));
+  padding: var(--padding);
+  padding-left: #{"max(var(--padding), env(safe-area-inset-left))"};
+  padding-right: #{"max(var(--padding), env(safe-area-inset-right))"};
+}
+
 .App-menu {
   display: grid;
 }
@@ -303,3 +355,13 @@ button,
   transform: translateX(-50%);
   padding: 10px 20px;
 }
+
+@media (max-width: 600px), (max-height: 500px) {
+  aside {
+    display: none;
+  }
+  .scroll-back-to-content {
+    bottom: 70px;
+    bottom: calc(70px + env(safe-area-inset-bottom));
+  }
+}

+ 1 - 0
src/types.ts

@@ -31,4 +31,5 @@ export type AppState = {
   selectedId?: string;
   isResizing: boolean;
   zoom: number;
+  openedMenu: "canvas" | "shape" | null;
 };