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

@ -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")}
<div className="panelColumn">
{actionManager.renderAction("changeStrokeColor")}
{(hasBackground(elementType) ||
targetElements.some(element => hasBackground(element.type))) && (
<>
{actionManager.renderAction("changeBackgroundColor")}
{actionManager.renderAction("changeFillStyle")}
</>
)}
{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,
);