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
This commit is contained in:
Jed Fox 2020-02-20 18:44:38 -05:00 committed by GitHub
parent 9439908b92
commit 7a7a73b78d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 358 additions and 125 deletions

View File

@ -5,7 +5,7 @@
<title>Excalidraw</title> <title>Excalidraw</title>
<meta <meta
name="viewport" 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" /> <meta name="theme-color" content="#000000" />
<!-- prettier-ignore --> <!-- prettier-ignore -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -104,6 +104,7 @@ import { LanguageList } from "./components/LanguageList";
import { Point } from "roughjs/bin/geometry"; import { Point } from "roughjs/bin/geometry";
import { t, languages, setLanguage, getLanguage } from "./i18n"; import { t, languages, setLanguage, getLanguage } from "./i18n";
import { HintViewer } from "./components/HintViewer"; import { HintViewer } from "./components/HintViewer";
import useIsMobile, { IsMobileProvider } from "./is-mobile";
import { copyToAppClipboard, getClipboardContent } from "./clipboard"; import { copyToAppClipboard, getClipboardContent } from "./clipboard";
import { normalizeScroll } from "./scene/data"; import { normalizeScroll } from "./scene/data";
@ -135,6 +136,18 @@ const MOUSE_BUTTON = {
SECONDARY: 2, 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; let lastMouseUp: ((e: any) => void) | null = null;
export function viewportCoordsToSceneCoords( export function viewportCoordsToSceneCoords(
@ -211,12 +224,10 @@ const LayerUI = React.memo(
language, language,
setElements, setElements,
}: LayerUIProps) => { }: LayerUIProps) => {
function renderCanvasActions() { const isMobile = useIsMobile();
function renderExportDialog() {
return ( return (
<Stack.Col gap={4}>
<Stack.Row justifyContent={"space-between"}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
<ExportDialog <ExportDialog
elements={elements} elements={elements}
appState={appState} appState={appState}
@ -265,10 +276,6 @@ const LayerUI = React.memo(
} }
}} }}
/> />
{actionManager.renderAction("clearCanvas")}
</Stack.Row>
{actionManager.renderAction("changeViewBackgroundColor")}
</Stack.Col>
); );
} }
@ -284,7 +291,6 @@ const LayerUI = React.memo(
} }
return ( return (
<Island padding={4}>
<div className="panelColumn"> <div className="panelColumn">
{actionManager.renderAction("changeStrokeColor")} {actionManager.renderAction("changeStrokeColor")}
{(hasBackground(elementType) || {(hasBackground(elementType) ||
@ -328,7 +334,6 @@ const LayerUI = React.memo(
{actionManager.renderAction("deleteSelectedElements")} {actionManager.renderAction("deleteSelectedElements")}
</div> </div>
</Island>
); );
} }
@ -378,39 +383,7 @@ const LayerUI = React.memo(
); );
} }
return ( const lockButton = (
<>
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col gap={4} align="end">
<section
className="App-right-menu"
aria-labelledby="canvas-actions-title"
>
<h2 className="visually-hidden" id="canvas-actions-title">
{t("headings.canvasActions")}
</h2>
<Island padding={4}>{renderCanvasActions()}</Island>
</section>
<section
className="App-right-menu"
aria-labelledby="selected-shape-title"
>
<h2 className="visually-hidden" id="selected-shape-title">
{t("headings.selectedShapeActions")}
</h2>
{renderSelectedShapeActions(elements)}
</section>
</Stack.Col>
<section aria-labelledby="shapes-title">
<Stack.Col gap={4} align="start">
<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>
<LockIcon <LockIcon
checked={appState.elementLocked} checked={appState.elementLocked}
onChange={() => { onChange={() => {
@ -423,6 +396,157 @@ const LayerUI = React.memo(
}} }}
title={t("toolBar.lock")} 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">
<Stack.Col gap={4} align="end">
<section
className="App-right-menu"
aria-labelledby="canvas-actions-title"
>
<h2 className="visually-hidden" id="canvas-actions-title">
{t("headings.canvasActions")}
</h2>
<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"
aria-labelledby="selected-shape-title"
>
<h2 className="visually-hidden" id="selected-shape-title">
{t("headings.selectedShapeActions")}
</h2>
<Island padding={4}>
{renderSelectedShapeActions(elements)}
</Island>
</section>
</Stack.Col>
<section aria-labelledby="shapes-title">
<Stack.Col gap={4} align="start">
<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>
{lockButton}
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
</section> </section>
@ -2204,7 +2328,9 @@ class TopErrorBoundary extends React.Component {
ReactDOM.render( ReactDOM.render(
<TopErrorBoundary> <TopErrorBoundary>
<IsMobileProvider>
<App /> <App />
</IsMobileProvider>
</TopErrorBoundary>, </TopErrorBoundary>,
rootElement, rootElement,
); );

25
src/is-mobile.tsx Normal file
View File

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

View File

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

View File

@ -2,11 +2,16 @@
body { body {
margin: 0; margin: 0;
font-family: Arial, Helvetica, sans-serif; --ui-font: Arial, Helvetica, sans-serif;
font-family: var(--ui-font);
color: var(--text-color-primary); color: var(--text-color-primary);
-webkit-text-size-adjust: 100%;
} }
canvas { canvas {
touch-action: none;
user-select: none;
// following props improve blurriness at certain devicePixelRatios. // following props improve blurriness at certain devicePixelRatios.
// AFAIK it doesn't affect export (in fact, export seems sharp either way). // AFAIK it doesn't affect export (in fact, export seems sharp either way).
@ -24,6 +29,11 @@ canvas {
right: 0; right: 0;
} }
.panelRow {
display: flex;
justify-content: space-between;
}
.panelColumn { .panelColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -91,6 +101,7 @@ input:focus {
button, button,
.buttonList label { .buttonList label {
user-select: none;
background-color: #e9ecef; background-color: #e9ecef;
border: 0; border: 0;
border-radius: 4px; 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 { .App-menu {
display: grid; display: grid;
} }
@ -303,3 +355,13 @@ button,
transform: translateX(-50%); transform: translateX(-50%);
padding: 10px 20px; 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));
}
}

View File

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