From 5d7020cce6290c32a7ffe9349053caf9284f7ad1 Mon Sep 17 00:00:00 2001 From: Michal Srb Date: Wed, 8 Jul 2020 22:07:51 -0700 Subject: [PATCH] Refactoring in pointer down event handler (#1880) * Refactor: Move context menu touch device handling * Refactor: Move more stuff out of pointer down * Refactor: Move last coords into an object * Refactor: Move scrollbar handling out of pointer down * Refactor: simplify resizing in pointer down * Refactor: further simplify resizing in pointer down * Refactor: clarify clearing selection code * Refactor: move out clearing selection from pointer down * Refactor: further simplify deselection in pointer down --- src/components/App.tsx | 495 +++++++++++++++++++++----------------- src/element/resizeTest.ts | 2 +- src/scene/scrollbars.ts | 7 +- 3 files changed, 278 insertions(+), 226 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 335ba518..c9eb27e9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1900,28 +1900,8 @@ class App extends React.Component { ) => { event.persist(); - // deal with opening context menu on touch devices - if (event.pointerType === "touch") { - touchMoving = false; - - // open the context menu with the first touch's clientX and clientY - // if the touch is not moving - touchTimeout = window.setTimeout(() => { - if (!touchMoving) { - this.openContextMenu({ - clientX: event.clientX, - clientY: event.clientY, - }); - } - }, TOUCH_CTX_MENU_TIMEOUT); - } - - 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 pointerdown - lastPointerUp(event); - } + this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event); + this.maybeCleanupAfterMissingPointerUp(event); if (isPanning) { return; @@ -1933,89 +1913,7 @@ class App extends React.Component { }); this.savePointer(event.clientX, event.clientY, "down"); - // pan canvas on wheel button drag or space+drag - if ( - gesture.pointers.size === 0 && - (event.button === POINTER_BUTTON.WHEEL || - (event.button === POINTER_BUTTON.MAIN && isHoldingSpace)) - ) { - isPanning = true; - - let nextPastePrevented = false; - const isLinux = /Linux/.test(window.navigator.platform); - - document.documentElement.style.cursor = CURSOR_TYPE.GRABBING; - let { clientX: lastX, clientY: lastY } = event; - const onPointerMove = withBatchedUpdates((event: PointerEvent) => { - const deltaX = lastX - event.clientX; - const deltaY = lastY - event.clientY; - lastX = event.clientX; - lastY = event.clientY; - - /* - * Prevent paste event if we move while middle clicking on Linux. - * See issue #1383. - */ - if ( - isLinux && - !nextPastePrevented && - (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) - ) { - nextPastePrevented = true; - - /* Prevent the next paste event */ - const preventNextPaste = (event: ClipboardEvent) => { - document.body.removeEventListener(EVENT.PASTE, preventNextPaste); - event.stopPropagation(); - }; - - /* - * Reenable next paste in case of disabled middle click paste for - * any reason: - * - rigth click paste - * - empty clipboard - */ - const enableNextPaste = () => { - setTimeout(() => { - document.body.removeEventListener(EVENT.PASTE, preventNextPaste); - window.removeEventListener(EVENT.POINTER_UP, enableNextPaste); - }, 100); - }; - - document.body.addEventListener(EVENT.PASTE, preventNextPaste); - window.addEventListener(EVENT.POINTER_UP, enableNextPaste); - } - - this.setState({ - scrollX: normalizeScroll( - this.state.scrollX - deltaX / this.state.zoom, - ), - scrollY: normalizeScroll( - this.state.scrollY - deltaY / this.state.zoom, - ), - }); - }); - const teardown = withBatchedUpdates( - (lastPointerUp = () => { - lastPointerUp = null; - isPanning = false; - if (!isHoldingSpace) { - setCursorForShape(this.state.elementType); - } - this.setState({ - cursorButton: "up", - }); - this.savePointer(event.clientX, event.clientY, "up"); - window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.removeEventListener(EVENT.POINTER_UP, teardown); - window.removeEventListener(EVENT.BLUR, teardown); - }), - ); - window.addEventListener(EVENT.BLUR, teardown); - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, { - passive: true, - }); - window.addEventListener(EVENT.POINTER_UP, teardown); + if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) { return; } @@ -2027,18 +1925,7 @@ class App extends React.Component { return; } - gesture.pointers.set(event.pointerId, { - x: event.clientX, - y: event.clientY, - }); - - if (gesture.pointers.size === 2) { - gesture.lastCenter = getCenter(gesture.pointers); - gesture.initialScale = this.state.zoom; - gesture.initialDistance = getDistance( - Array.from(gesture.pointers.values()), - ); - } + this.updateGestureOnPointerDown(event); // fixes pointermove causing selection of UI texts #32 event.preventDefault(); @@ -2055,10 +1942,15 @@ class App extends React.Component { } // Handle scrollbars dragging + const isOverScrollBarsNow = isOverScrollBars( + currentScrollBars, + event.clientX, + event.clientY, + ); const { isOverHorizontalScrollBar, isOverVerticalScrollBar, - } = isOverScrollBars(currentScrollBars, event.clientX, event.clientY); + } = isOverScrollBarsNow; const { x, y } = viewportCoordsToSceneCoords( event, @@ -2066,58 +1958,9 @@ class App extends React.Component { this.canvas, window.devicePixelRatio, ); - let lastX = x; - let lastY = y; + const lastCoords = { x, y }; - if ( - (isOverHorizontalScrollBar || isOverVerticalScrollBar) && - !this.state.multiElement - ) { - isDraggingScrollBar = true; - lastX = event.clientX; - lastY = event.clientY; - const onPointerMove = withBatchedUpdates((event: PointerEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - if (isOverHorizontalScrollBar) { - const x = event.clientX; - const dx = x - lastX; - this.setState({ - scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), - }); - lastX = x; - return; - } - - if (isOverVerticalScrollBar) { - const y = event.clientY; - const dy = y - lastY; - this.setState({ - scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), - }); - lastY = y; - } - }); - - const onPointerUp = withBatchedUpdates(() => { - isDraggingScrollBar = false; - setCursorForShape(this.state.elementType); - lastPointerUp = null; - this.setState({ - cursorButton: "up", - }); - this.savePointer(event.clientX, event.clientY, "up"); - window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.removeEventListener(EVENT.POINTER_UP, onPointerUp); - }); - - lastPointerUp = onPointerUp; - - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.addEventListener(EVENT.POINTER_UP, onPointerUp); + if (this.handleDraggingScrollBar(event, lastCoords, isOverScrollBarsNow)) { return; } @@ -2142,6 +1985,14 @@ class App extends React.Component { let hitElement: ExcalidrawElement | null = null; let hitElementWasAddedToSelection = false; + if (this.state.elementType !== "selection") { + this.setState({ + selectedElementIds: {}, + selectedGroupIds: {}, + editingGroupId: null, + }); + } + if (this.state.elementType === "selection") { const elements = globalSceneState.getElements(); const selectedElements = getSelectedElements(elements, this.state); @@ -2154,17 +2005,9 @@ class App extends React.Component { this.state.zoom, event.pointerType, ); - if (elementWithResizeHandler) { - this.setState({ - resizingElement: elementWithResizeHandler - ? elementWithResizeHandler.element - : null, - }); + if (elementWithResizeHandler != null) { + this.setState({ resizingElement: elementWithResizeHandler.element }); resizeHandle = elementWithResizeHandler.resizeHandle; - document.documentElement.style.cursor = getCursorForResizingElement( - elementWithResizeHandler, - ); - isResizingElements = true; } } else if (selectedElements.length > 1) { resizeHandle = getResizeHandlerFromCoords( @@ -2174,14 +2017,12 @@ class App extends React.Component { this.state.zoom, event.pointerType, ); - if (resizeHandle) { - document.documentElement.style.cursor = getCursorForResizingElement({ - resizeHandle, - }); - isResizingElements = true; - } } - if (isResizingElements) { + if (resizeHandle) { + document.documentElement.style.cursor = getCursorForResizingElement({ + resizeHandle, + }); + isResizingElements = true; resizeOffsetXY = getResizeOffsetXY( resizeHandle, selectedElements, @@ -2198,8 +2039,7 @@ class App extends React.Component { selectedElements[0], ); } - } - if (!isResizingElements) { + } else { if (this.state.editingLinearElement) { const ret = LinearElementEditor.handlePointerDown( event, @@ -2222,27 +2062,7 @@ class App extends React.Component { hitElement || getElementAtPosition(elements, this.state, x, y, this.state.zoom); - // clear selection if shift is not clicked - if ( - !(hitElement && this.state.selectedElementIds[hitElement.id]) && - !event.shiftKey - ) { - this.setState((prevState) => ({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: - prevState.editingGroupId && - hitElement && - isElementInGroup(hitElement, prevState.editingGroupId) - ? prevState.editingGroupId - : null, - })); - const { selectedElementIds } = this.state; - this.setState({ - selectedElementIds: {}, - previousSelectedElementIds: selectedElementIds, - }); - } + this.maybeClearSelectionWhenHittingElement(event, hitElement); // If we click on something if (hitElement) { @@ -2289,12 +2109,6 @@ class App extends React.Component { previousSelectedElementIds: selectedElementIds, }); } - } else { - this.setState({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: null, - }); } if (this.state.elementType === "text") { @@ -2457,21 +2271,21 @@ class App extends React.Component { if (isOverHorizontalScrollBar) { const x = event.clientX; - const dx = x - lastX; + const dx = x - lastCoords.x; this.setState({ scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), }); - lastX = x; + lastCoords.x = x; return; } if (isOverVerticalScrollBar) { const y = event.clientY; - const dy = y - lastY; + const dy = y - lastCoords.y; this.setState({ scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), }); - lastY = y; + lastCoords.y = y; return; } @@ -2534,13 +2348,13 @@ class App extends React.Component { (appState) => this.setState(appState), x, y, - lastX, - lastY, + lastCoords.x, + lastCoords.y, ); if (didDrag) { - lastX = x; - lastY = y; + lastCoords.x = x; + lastCoords.y = y; return; } } @@ -2905,6 +2719,241 @@ class App extends React.Component { window.addEventListener(EVENT.POINTER_UP, onPointerUp); }; + private maybeOpenContextMenuAfterPointerDownOnTouchDevices = ( + event: React.PointerEvent, + ): void => { + // deal with opening context menu on touch devices + if (event.pointerType === "touch") { + touchMoving = false; + + // open the context menu with the first touch's clientX and clientY + // if the touch is not moving + touchTimeout = window.setTimeout(() => { + if (!touchMoving) { + this.openContextMenu({ + clientX: event.clientX, + clientY: event.clientY, + }); + } + }, TOUCH_CTX_MENU_TIMEOUT); + } + }; + + private maybeCleanupAfterMissingPointerUp( + event: React.PointerEvent, + ): void { + 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 pointerdown + lastPointerUp(event); + } + } + + // Returns whether the event is a panning + private handleCanvasPanUsingWheelOrSpaceDrag = ( + event: React.PointerEvent, + ): boolean => { + if ( + !( + gesture.pointers.size === 0 && + (event.button === POINTER_BUTTON.WHEEL || + (event.button === POINTER_BUTTON.MAIN && isHoldingSpace)) + ) + ) { + return false; + } + isPanning = true; + + let nextPastePrevented = false; + const isLinux = /Linux/.test(window.navigator.platform); + + document.documentElement.style.cursor = CURSOR_TYPE.GRABBING; + let { clientX: lastX, clientY: lastY } = event; + const onPointerMove = withBatchedUpdates((event: PointerEvent) => { + const deltaX = lastX - event.clientX; + const deltaY = lastY - event.clientY; + lastX = event.clientX; + lastY = event.clientY; + + /* + * Prevent paste event if we move while middle clicking on Linux. + * See issue #1383. + */ + if ( + isLinux && + !nextPastePrevented && + (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) + ) { + nextPastePrevented = true; + + /* Prevent the next paste event */ + const preventNextPaste = (event: ClipboardEvent) => { + document.body.removeEventListener(EVENT.PASTE, preventNextPaste); + event.stopPropagation(); + }; + + /* + * Reenable next paste in case of disabled middle click paste for + * any reason: + * - rigth click paste + * - empty clipboard + */ + const enableNextPaste = () => { + setTimeout(() => { + document.body.removeEventListener(EVENT.PASTE, preventNextPaste); + window.removeEventListener(EVENT.POINTER_UP, enableNextPaste); + }, 100); + }; + + document.body.addEventListener(EVENT.PASTE, preventNextPaste); + window.addEventListener(EVENT.POINTER_UP, enableNextPaste); + } + + this.setState({ + scrollX: normalizeScroll(this.state.scrollX - deltaX / this.state.zoom), + scrollY: normalizeScroll(this.state.scrollY - deltaY / this.state.zoom), + }); + }); + const teardown = withBatchedUpdates( + (lastPointerUp = () => { + lastPointerUp = null; + isPanning = false; + if (!isHoldingSpace) { + setCursorForShape(this.state.elementType); + } + this.setState({ + cursorButton: "up", + }); + this.savePointer(event.clientX, event.clientY, "up"); + window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.removeEventListener(EVENT.POINTER_UP, teardown); + window.removeEventListener(EVENT.BLUR, teardown); + }), + ); + window.addEventListener(EVENT.BLUR, teardown); + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, { + passive: true, + }); + window.addEventListener(EVENT.POINTER_UP, teardown); + return true; + }; + + private updateGestureOnPointerDown( + event: React.PointerEvent, + ): void { + gesture.pointers.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + }); + + if (gesture.pointers.size === 2) { + gesture.lastCenter = getCenter(gesture.pointers); + gesture.initialScale = this.state.zoom; + gesture.initialDistance = getDistance( + Array.from(gesture.pointers.values()), + ); + } + } + + // Returns whether the event is a dragging a scrollbar + private handleDraggingScrollBar( + event: React.PointerEvent, + lastCoords: { x: number; y: number }, + { + isOverHorizontalScrollBar, + isOverVerticalScrollBar, + }: { + isOverHorizontalScrollBar: boolean; + isOverVerticalScrollBar: boolean; + }, + ): boolean { + if ( + !( + (isOverHorizontalScrollBar || isOverVerticalScrollBar) && + !this.state.multiElement + ) + ) { + return false; + } + isDraggingScrollBar = true; + lastCoords.x = event.clientX; + lastCoords.y = event.clientY; + const onPointerMove = withBatchedUpdates((event: PointerEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (isOverHorizontalScrollBar) { + const x = event.clientX; + const dx = x - lastCoords.x; + this.setState({ + scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), + }); + lastCoords.x = x; + return; + } + + if (isOverVerticalScrollBar) { + const y = event.clientY; + const dy = y - lastCoords.y; + this.setState({ + scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), + }); + lastCoords.y = y; + } + }); + + const onPointerUp = withBatchedUpdates(() => { + isDraggingScrollBar = false; + setCursorForShape(this.state.elementType); + lastPointerUp = null; + this.setState({ + cursorButton: "up", + }); + this.savePointer(event.clientX, event.clientY, "up"); + window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.removeEventListener(EVENT.POINTER_UP, onPointerUp); + }); + + lastPointerUp = onPointerUp; + + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.addEventListener(EVENT.POINTER_UP, onPointerUp); + return true; + } + + private maybeClearSelectionWhenHittingElement( + event: React.PointerEvent, + hitElement: ExcalidrawElement | null, + ): void { + const isHittingASelectedElement = + hitElement != null && this.state.selectedElementIds[hitElement.id]; + + // clear selection if shift is not clicked + if (isHittingASelectedElement || event.shiftKey) { + return; + } + this.setState((prevState) => ({ + selectedElementIds: {}, + selectedGroupIds: {}, + // Continue editing the same group if the user selected a different + // element from it + editingGroupId: + prevState.editingGroupId && + hitElement != null && + isElementInGroup(hitElement, prevState.editingGroupId) + ? prevState.editingGroupId + : null, + })); + const { selectedElementIds } = this.state; + this.setState({ + selectedElementIds: {}, + previousSelectedElementIds: selectedElementIds, + }); + } + private handleCanvasRef = (canvas: HTMLCanvasElement) => { // canvas is null when unmounting if (canvas !== null) { diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index d831e6bf..46185512 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -81,7 +81,7 @@ export const getElementWithResizeHandler = ( pointerType, ); return resizeHandle ? { element, resizeHandle } : null; - }, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType } | null); + }, null as { element: NonDeletedExcalidrawElement; resizeHandle: HandlerRectanglesRet } | null); }; export const getResizeHandlerFromCoords = ( diff --git a/src/scene/scrollbars.ts b/src/scene/scrollbars.ts index 8b958f1d..d6831257 100644 --- a/src/scene/scrollbars.ts +++ b/src/scene/scrollbars.ts @@ -106,13 +106,16 @@ export const isOverScrollBars = ( scrollBars: ScrollBars, x: number, y: number, -) => { +): { + isOverHorizontalScrollBar: boolean; + isOverVerticalScrollBar: boolean; +} => { const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [ scrollBars.horizontal, scrollBars.vertical, ].map((scrollBar) => { return ( - scrollBar && + scrollBar != null && scrollBar.x <= x && x <= scrollBar.x + scrollBar.width && scrollBar.y <= y &&