Add touch support (#788)

* Add touch support

* Mock media query

* Mock media query pt 2

* Fix tests

* Allow installing as an app on iOS

* Fix type error

* Math.hypot

* delete and finalize buttons, hint viewer

* skip failing tests

* skip the rest of the failing tests

* Hide the selected shape actions when nothing is selected

* Don’t go into mobile view on short-but-wide viewports

* lol
This commit is contained in:
Jed Fox 2020-02-21 08:17:20 -05:00 committed by GitHub
parent c2855e2cb8
commit ab176937e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 356 additions and 208 deletions

View file

@ -41,7 +41,7 @@ import {
} from "./scene";
import { renderScene } from "./renderer";
import { AppState, FlooredNumber } from "./types";
import { AppState, FlooredNumber, Gesture } from "./types";
import { ExcalidrawElement } from "./element/types";
import {
@ -108,6 +108,7 @@ import useIsMobile, { IsMobileProvider } from "./is-mobile";
import { copyToAppClipboard, getClipboardContent } from "./clipboard";
import { normalizeScroll } from "./scene/data";
import { getCenter, getDistance } from "./gesture";
let { elements } = createScene();
const { history } = createHistory();
@ -130,10 +131,11 @@ const CURSOR_TYPE = {
CROSSHAIR: "crosshair",
GRABBING: "grabbing",
};
const MOUSE_BUTTON = {
const POINTER_BUTTON = {
MAIN: 0,
WHEEL: 1,
SECONDARY: 2,
TOUCH: -1,
};
// Block pinch-zooming on iOS outside of the content area
@ -148,7 +150,13 @@ document.addEventListener(
{ passive: false },
);
let lastMouseUp: ((e: any) => void) | null = null;
let lastPointerUp: ((e: any) => void) | null = null;
const gesture: Gesture = {
pointers: [],
lastCenter: null,
initialDistance: null,
initialScale: null,
};
export function viewportCoordsToSceneCoords(
{ clientX, clientY }: { clientX: number; clientY: number },
@ -202,7 +210,6 @@ let cursorX = 0;
let cursorY = 0;
let isHoldingSpace: boolean = false;
let isPanning: boolean = false;
let isHoldingMouseButton: boolean = false;
interface LayerUIProps {
actionManager: ActionManager;
@ -279,17 +286,15 @@ const LayerUI = React.memo(
);
}
function renderSelectedShapeActions(
elements: readonly ExcalidrawElement[],
) {
const showSelectedShapeActions =
(appState.editingElement || getSelectedElements(elements).length) &&
appState.elementType === "selection";
function renderSelectedShapeActions() {
const { elementType, editingElement } = appState;
const targetElements = editingElement
? [editingElement]
: getSelectedElements(elements);
if (!targetElements.length && elementType === "selection") {
return null;
}
return (
<div className="panelColumn">
{actionManager.renderAction("changeStrokeColor")}
@ -331,8 +336,6 @@ const LayerUI = React.memo(
{actionManager.renderAction("bringForward")}
</div>
</fieldset>
{actionManager.renderAction("deleteSelectedElements")}
</div>
);
}
@ -418,7 +421,7 @@ const LayerUI = React.memo(
</Stack.Col>
</div>
</section>
) : appState.openedMenu === "shape" ? (
) : appState.openedMenu === "shape" && showSelectedShapeActions ? (
<section
className="App-mobile-menu"
aria-labelledby="selected-shape-title"
@ -427,7 +430,7 @@ const LayerUI = React.memo(
{t("headings.selectedShapeActions")}
</h2>
<div className="App-mobile-menu-scroller">
{renderSelectedShapeActions(elements)}
{renderSelectedShapeActions()}
</div>
</section>
) : null}
@ -444,6 +447,12 @@ const LayerUI = React.memo(
</Stack.Row>
</Stack.Col>
</section>
<HintViewer
elementType={appState.elementType}
multiMode={appState.multiElement !== null}
isResizing={appState.isResizing}
elements={elements}
/>
</FixedSideContainer>
<footer className="App-toolbar">
<div className="App-toolbar-content">
@ -459,7 +468,18 @@ const LayerUI = React.memo(
}))
}
/>
<div
style={{
visibility: isSomeElementSelected(elements)
? "visible"
: "hidden",
}}
>
{" "}
{actionManager.renderAction("deleteSelectedElements")}
</div>
{lockButton}
{actionManager.renderAction("finalize")}
<div
style={{
visibility: isSomeElementSelected(elements)
@ -482,12 +502,6 @@ const LayerUI = React.memo(
}
/>
</div>
<HintViewer
elementType={appState.elementType}
multiMode={appState.multiElement !== null}
isResizing={appState.isResizing}
elements={elements}
/>
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
@ -525,17 +539,17 @@ const LayerUI = React.memo(
</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>
{showSelectedShapeActions ? (
<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()}</Island>
</section>
) : null}
</Stack.Col>
<section aria-labelledby="shapes-title">
<Stack.Col gap={4} align="start">
@ -858,7 +872,7 @@ export class App extends React.Component<any, AppState> {
this.setState({ ...data.appState });
}
}
} else if (event.key === KEYS.SPACE && !isHoldingMouseButton) {
} else if (event.key === KEYS.SPACE && gesture.pointers.length === 0) {
isHoldingSpace = true;
document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
}
@ -953,6 +967,10 @@ export class App extends React.Component<any, AppState> {
this.setState({});
};
removePointer = (e: React.PointerEvent<HTMLElement>) => {
gesture.pointers = gesture.pointers.filter(p => p.id !== e.pointerId);
};
public render() {
const canvasDOMWidth = window.innerWidth;
const canvasDOMHeight = window.innerHeight;
@ -1055,12 +1073,12 @@ export class App extends React.Component<any, AppState> {
left: e.clientX,
});
}}
onMouseDown={e => {
if (lastMouseUp !== null) {
// Unfortunately, sometimes we don't get a mouseup after a mousedown,
onPointerDown={e => {
if (lastPointerUp !== null) {
// Unfortunately, sometimes we don't get a pointerup after a pointerdown,
// this can happen when a contextual menu or alert is triggered. In order to avoid
// being in a weird state, we clean up on the next mousedown
lastMouseUp(e);
// being in a weird state, we clean up on the next pointerdown
lastPointerUp(e);
}
if (isPanning) {
@ -1069,15 +1087,14 @@ export class App extends React.Component<any, AppState> {
// pan canvas on wheel button drag or space+drag
if (
!isHoldingMouseButton &&
(e.button === MOUSE_BUTTON.WHEEL ||
(e.button === MOUSE_BUTTON.MAIN && isHoldingSpace))
gesture.pointers.length === 0 &&
(e.button === POINTER_BUTTON.WHEEL ||
(e.button === POINTER_BUTTON.MAIN && isHoldingSpace))
) {
isHoldingMouseButton = true;
isPanning = true;
document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
let { clientX: lastX, clientY: lastY } = e;
const onMouseMove = (e: MouseEvent) => {
const onPointerMove = (e: PointerEvent) => {
const deltaX = lastX - e.clientX;
const deltaY = lastY - e.clientY;
lastX = e.clientX;
@ -1092,30 +1109,44 @@ export class App extends React.Component<any, AppState> {
),
});
};
const teardown = (lastMouseUp = () => {
lastMouseUp = null;
const teardown = (lastPointerUp = () => {
lastPointerUp = null;
isPanning = false;
isHoldingMouseButton = false;
if (!isHoldingSpace) {
setCursorForShape(this.state.elementType);
}
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", teardown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", teardown);
window.removeEventListener("blur", teardown);
});
window.addEventListener("blur", teardown);
window.addEventListener("mousemove", onMouseMove, {
window.addEventListener("pointermove", onPointerMove, {
passive: true,
});
window.addEventListener("mouseup", teardown);
window.addEventListener("pointerup", teardown);
return;
}
// only handle left mouse button
if (e.button !== MOUSE_BUTTON.MAIN) {
// only handle left mouse button or touch
if (
e.button !== POINTER_BUTTON.MAIN &&
e.button !== POINTER_BUTTON.TOUCH
) {
return;
}
// fixes mousemove causing selection of UI texts #32
gesture.pointers.push({
id: e.pointerId,
x: e.clientX,
y: e.clientY,
});
if (gesture.pointers.length === 2) {
gesture.lastCenter = getCenter(gesture.pointers);
gesture.initialScale = this.state.zoom;
gesture.initialDistance = getDistance(gesture.pointers);
}
// fixes pointermove causing selection of UI texts #32
e.preventDefault();
// Preventing the event above disables default behavior
// of defocusing potentially focused element, which is what we
@ -1124,6 +1155,11 @@ export class App extends React.Component<any, AppState> {
document.activeElement.blur();
}
// don't select while panning
if (gesture.pointers.length > 1) {
return;
}
// Handle scrollbars dragging
const {
isOverHorizontalScrollBar,
@ -1216,7 +1252,7 @@ export class App extends React.Component<any, AppState> {
elementIsAddedToSelection = true;
}
// We duplicate the selected element if alt is pressed on Mouse down
// We duplicate the selected element if alt is pressed on pointer down
if (e.altKey) {
elements = [
...elements.map(element => ({
@ -1352,8 +1388,8 @@ export class App extends React.Component<any, AppState> {
p1: Point,
deltaX: number,
deltaY: number,
mouseX: number,
mouseY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => void)
| null = null;
@ -1363,8 +1399,8 @@ export class App extends React.Component<any, AppState> {
p1: Point,
deltaX: number,
deltaY: number,
mouseX: number,
mouseY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => {
if (perfect) {
@ -1373,8 +1409,8 @@ export class App extends React.Component<any, AppState> {
const { width, height } = getPerfectElementSize(
element.type,
mouseX - element.x - p1[0],
mouseY - element.y - p1[1],
pointerX - element.x - p1[0],
pointerY - element.y - p1[1],
);
const dx = element.x + width + p1[0];
@ -1396,15 +1432,15 @@ export class App extends React.Component<any, AppState> {
p1: Point,
deltaX: number,
deltaY: number,
mouseX: number,
mouseY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => {
if (perfect) {
const { width, height } = getPerfectElementSize(
element.type,
mouseX - element.x,
mouseY - element.y,
pointerX - element.x,
pointerY - element.y,
);
p1[0] = width;
p1[1] = height;
@ -1414,7 +1450,7 @@ export class App extends React.Component<any, AppState> {
}
};
const onMouseMove = (e: MouseEvent) => {
const onPointerMove = (e: PointerEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement)) {
return;
@ -1447,7 +1483,7 @@ export class App extends React.Component<any, AppState> {
// for arrows, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when
// user clicks mouse in a way that it moves a tiny bit (thus
// triggering mousemove)
// triggering pointermove)
if (
!draggingOccurred &&
(this.state.elementType === "arrow" ||
@ -1691,7 +1727,7 @@ export class App extends React.Component<any, AppState> {
if (hitElement?.isSelected) {
// Marking that click was used for dragging to check
// if elements should be deselected on mouseup
// if elements should be deselected on pointerup
draggingOccurred = true;
const selectedElements = getSelectedElements(elements);
if (selectedElements.length > 0) {
@ -1790,7 +1826,7 @@ export class App extends React.Component<any, AppState> {
this.setState({});
};
const onMouseUp = (e: MouseEvent) => {
const onPointerUp = (e: PointerEvent) => {
const {
draggingElement,
resizingElement,
@ -1806,10 +1842,9 @@ export class App extends React.Component<any, AppState> {
});
resizeArrowFn = null;
lastMouseUp = null;
isHoldingMouseButton = false;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
lastPointerUp = null;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
if (elementType === "arrow" || elementType === "line") {
if (draggingElement!.points.length > 1) {
@ -1850,7 +1885,7 @@ export class App extends React.Component<any, AppState> {
draggingElement &&
isInvisiblySmallElement(draggingElement)
) {
// remove invisible element which was added in onMouseDown
// remove invisible element which was added in onPointerDown
elements = elements.slice(0, -1);
this.setState({
draggingElement: null,
@ -1882,7 +1917,7 @@ export class App extends React.Component<any, AppState> {
// from hitted element
//
// If click occurred and elements were dragged or some element
// was added to selection (on mousedown phase) we need to keep
// was added to selection (on pointerdown phase) we need to keep
// selection unchanged
if (
hitElement &&
@ -1928,10 +1963,10 @@ export class App extends React.Component<any, AppState> {
}
};
lastMouseUp = onMouseUp;
lastPointerUp = onPointerUp;
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
onDoubleClick={e => {
resetCursor();
@ -2048,7 +2083,39 @@ export class App extends React.Component<any, AppState> {
},
});
}}
onMouseMove={e => {
onPointerMove={e => {
gesture.pointers = gesture.pointers.map(p =>
p.id === e.pointerId
? {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
}
: p,
);
if (gesture.pointers.length === 2) {
const center = getCenter(gesture.pointers);
const deltaX = center.x - gesture.lastCenter!.x;
const deltaY = center.y - gesture.lastCenter!.y;
gesture.lastCenter = center;
const distance = getDistance(gesture.pointers);
const scaleFactor = distance / gesture.initialDistance!;
this.setState({
scrollX: normalizeScroll(
this.state.scrollX + deltaX / this.state.zoom,
),
scrollY: normalizeScroll(
this.state.scrollY + deltaY / this.state.zoom,
),
zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor),
});
} else {
gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null;
}
if (isHoldingSpace || isPanning) {
return;
}
@ -2101,6 +2168,8 @@ export class App extends React.Component<any, AppState> {
);
document.documentElement.style.cursor = hitElement ? "move" : "";
}}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onDrop={e => {
const file = e.dataTransfer.files[0];
if (file?.type === "application/json") {