mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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
13 changed files with 358 additions and 125 deletions
346
src/index.tsx
346
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")}
|
||||
<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,
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue