From 7a7a73b78df342507fffba48b054b41dd66422e7 Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Thu, 20 Feb 2020 18:44:38 -0500 Subject: [PATCH] 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 --- public/index.html | 2 +- src/actions/actionCanvas.tsx | 2 + src/actions/actionExport.tsx | 3 + src/appState.ts | 1 + src/components/ColorPicker.css | 2 + src/components/ExportDialog.tsx | 2 + src/components/ToolButton.tsx | 4 + src/components/ToolIcon.scss | 26 ++- src/index.tsx | 346 ++++++++++++++++++++++---------- src/is-mobile.tsx | 25 +++ src/locales/en.json | 5 +- src/styles.scss | 64 +++++- src/types.ts | 1 + 13 files changed, 358 insertions(+), 125 deletions(-) create mode 100644 src/is-mobile.tsx diff --git a/public/index.html b/public/index.html index 438f754a..e046b676 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ Excalidraw diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index d3044532..0e9dbf6f 100644 --- a/src/actions/actionCanvas.tsx +++ b/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. diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 481ac49f..29c5c31f 100644 --- a/src/actions/actionExport.tsx +++ b/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 }) => { diff --git a/src/appState.ts b/src/appState.ts index 84a9e990..9d4472fd 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -29,6 +29,7 @@ export function getDefaultAppState(): AppState { isResizing: false, selectionElement: null, zoom: 1, + openedMenu: null, }; } diff --git a/src/components/ColorPicker.css b/src/components/ColorPicker.css index 25194298..853450ac 100644 --- a/src/components/ColorPicker.css +++ b/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 { diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 1e504d26..51eb9759 100644 --- a/src/components/ExportDialog.tsx +++ b/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} /> diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index 62c89c0b..81de3545 100644 --- a/src/components/ToolButton.tsx +++ b/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( + {props.showAriaLabel && ( +
{props["aria-label"]}
+ )} ); } diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss index 1905a1d5..6ddf1a95 100644 --- a/src/components/ToolIcon.scss +++ b/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; } diff --git a/src/index.tsx b/src/index.tsx index 9f54a980..c40ca01b 100644 --- a/src/index.tsx +++ b/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 ( - - - {actionManager.renderAction("loadScene")} - {actionManager.renderAction("saveScene")} - { - 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")} - - {actionManager.renderAction("changeViewBackgroundColor")} - + { + 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 ( - -
- {actionManager.renderAction("changeStrokeColor")} - {(hasBackground(elementType) || - targetElements.some(element => hasBackground(element.type))) && ( - <> - {actionManager.renderAction("changeBackgroundColor")} +
+ {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")} -
- {t("labels.layers")} -
- {actionManager.renderAction("sendToBack")} - {actionManager.renderAction("sendBackward")} - {actionManager.renderAction("bringToFront")} - {actionManager.renderAction("bringForward")} -
-
+
+ {t("labels.layers")} +
+ {actionManager.renderAction("sendToBack")} + {actionManager.renderAction("sendBackward")} + {actionManager.renderAction("bringToFront")} + {actionManager.renderAction("bringForward")} +
+
- {actionManager.renderAction("deleteSelectedElements")} -
- + {actionManager.renderAction("deleteSelectedElements")} +
); } @@ -378,7 +383,125 @@ const LayerUI = React.memo( ); } - return ( + const lockButton = ( + { + setAppState({ + elementLocked: !appState.elementLocked, + elementType: appState.elementLocked + ? "selection" + : appState.elementType, + }); + }} + title={t("toolBar.lock")} + /> + ); + + return isMobile ? ( + <> + {appState.openedMenu === "canvas" ? ( +
+

+ {t("headings.canvasActions")} +

+
+ + {actionManager.renderAction("loadScene")} + {actionManager.renderAction("saveScene")} + {renderExportDialog()} + {actionManager.renderAction("clearCanvas")} + {actionManager.renderAction("changeViewBackgroundColor")} + +
+
+ ) : appState.openedMenu === "shape" ? ( +
+

+ {t("headings.selectedShapeActions")} +

+
+ {renderSelectedShapeActions(elements)} +
+
+ ) : null} + +
+ + + +

+ {t("headings.shapes")} +

+ {renderShapesSwitcher()} +
+
+
+
+
+
+
+ ☰ + } + aria-label={t("buttons.menu")} + onClick={() => + setAppState(({ openedMenu }: any) => ({ + openedMenu: openedMenu === "canvas" ? null : "canvas", + })) + } + /> + {lockButton} +
+ + ✎ + + } + aria-label={t("buttons.menu")} + onClick={() => + setAppState(({ openedMenu }: any) => ({ + openedMenu: openedMenu === "shape" ? null : "shape", + })) + } + /> +
+ + {appState.scrolledOutside && ( + + )} +
+
+ + ) : ( <>
@@ -390,7 +513,17 @@ const LayerUI = React.memo(

{t("headings.canvasActions")}

- {renderCanvasActions()} + + + + {actionManager.renderAction("loadScene")} + {actionManager.renderAction("saveScene")} + {renderExportDialog()} + {actionManager.renderAction("clearCanvas")} + + {actionManager.renderAction("changeViewBackgroundColor")} + +
{t("headings.selectedShapeActions")} - {renderSelectedShapeActions(elements)} + + {renderSelectedShapeActions(elements)} +
@@ -411,18 +546,7 @@ const LayerUI = React.memo( {renderShapesSwitcher()} - { - setAppState({ - elementLocked: !appState.elementLocked, - elementType: appState.elementLocked - ? "selection" - : appState.elementType, - }); - }} - title={t("toolBar.lock")} - /> + {lockButton}
@@ -2204,7 +2328,9 @@ class TopErrorBoundary extends React.Component { ReactDOM.render( - + + + , rootElement, ); diff --git a/src/is-mobile.tsx b/src/is-mobile.tsx new file mode 100644 index 00000000..e0cf092b --- /dev/null +++ b/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(); + 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 {children}; +} + +export default function useIsMobile() { + return useContext(context); +} diff --git a/src/locales/en.json b/src/locales/en.json index e6088437..02af6931 100644 --- a/src/locales/en.json +++ b/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?", diff --git a/src/styles.scss b/src/styles.scss index 17cbf318..93c32144 100644 --- a/src/styles.scss +++ b/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)); + } +} diff --git a/src/types.ts b/src/types.ts index 5c8f4433..308e93ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,4 +31,5 @@ export type AppState = { selectedId?: string; isResizing: boolean; zoom: number; + openedMenu: "canvas" | "shape" | null; };