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:
parent
9439908b92
commit
7a7a73b78d
@ -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 -->
|
||||
|
@ -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.
|
||||
|
@ -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 }) => {
|
||||
|
@ -29,6 +29,7 @@ export function getDefaultAppState(): AppState {
|
||||
isResizing: false,
|
||||
selectionElement: null,
|
||||
zoom: 1,
|
||||
openedMenu: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
214
src/index.tsx
214
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,12 +224,10 @@ 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}
|
||||
@ -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 (
|
||||
<Island padding={4}>
|
||||
<div className="panelColumn">
|
||||
{actionManager.renderAction("changeStrokeColor")}
|
||||
{(hasBackground(elementType) ||
|
||||
@ -328,7 +334,6 @@ const LayerUI = React.memo(
|
||||
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
</Island>
|
||||
);
|
||||
}
|
||||
|
||||
@ -378,39 +383,7 @@ const LayerUI = React.memo(
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
const lockButton = (
|
||||
<LockIcon
|
||||
checked={appState.elementLocked}
|
||||
onChange={() => {
|
||||
@ -423,6 +396,157 @@ const LayerUI = React.memo(
|
||||
}}
|
||||
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.Col>
|
||||
</section>
|
||||
@ -2204,7 +2328,9 @@ class TopErrorBoundary extends React.Component {
|
||||
|
||||
ReactDOM.render(
|
||||
<TopErrorBoundary>
|
||||
<IsMobileProvider>
|
||||
<App />
|
||||
</IsMobileProvider>
|
||||
</TopErrorBoundary>,
|
||||
rootElement,
|
||||
);
|
||||
|
25
src/is-mobile.tsx
Normal file
25
src/is-mobile.tsx
Normal 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);
|
||||
}
|
@ -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?",
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -31,4 +31,5 @@ export type AppState = {
|
||||
selectedId?: string;
|
||||
isResizing: boolean;
|
||||
zoom: number;
|
||||
openedMenu: "canvas" | "shape" | null;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user