From 4ab4fce9981afb1d6ee6d61a1e5f22a026f0fd5d Mon Sep 17 00:00:00 2001 From: Michal Srb Date: Thu, 9 Jul 2020 14:15:42 -0700 Subject: [PATCH] Refactoring in pointer down event handler, step 3 (#1888) * Refactor: use pointer down state for alt duplication flag * Refactor: use pointer down state for drag state * Refactor: simplify over scrollbars check * Refactor: move pointer move handler out of pointer down handler * Refactor: move pointer up handler out of pointer down handler * Refactor: further simplify scrollbar check state in pointer down event * Refactor: pull out initial pointer down state creation --- src/components/App.tsx | 1859 ++++++++++++++++++++------------------- src/scene/scrollbars.ts | 14 +- 2 files changed, 955 insertions(+), 918 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 1887c612d0..73c0816658 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -191,6 +191,10 @@ const gesture: Gesture = { type PointerDownState = Readonly<{ // The first position at which pointerDown happened origin: Readonly<{ x: number; y: number }>; + // Same as "origin" but snapped to the grid, if grid is on + originInGrid: Readonly<{ x: number; y: number }>; + // Scrollbar checks + scrollbars: ReturnType; // The previous pointer position lastCoords: { x: number; y: number }; resize: { @@ -209,6 +213,22 @@ type PointerDownState = Readonly<{ element: ExcalidrawElement | null; // This is determined on the initial pointer down event wasAddedToSelection: boolean; + // Whether selected element(s) were duplicated, might change during the + // pointer interation + hasBeenDuplicated: boolean; + }; + drag: { + // Might change during the pointer interation + hasOccurred: boolean; + // Might change during the pointer interation + offset: { x: number; y: number } | null; + }; + // We need to have these in the state so that we can unsubscribe them + eventListeners: { + // It's defined on the initial pointer down event + onMove: null | ((event: PointerEvent) => void); + // It's defined on the initial pointer down event + onUp: null | ((event: PointerEvent) => void); }; }>; @@ -1756,12 +1776,12 @@ class App extends React.Component { if (isHoldingSpace || isPanning || isDraggingScrollBar) { return; } - const { - isOverHorizontalScrollBar, - isOverVerticalScrollBar, - } = isOverScrollBars(currentScrollBars, event.clientX, event.clientY); - const isOverScrollBar = - isOverVerticalScrollBar || isOverHorizontalScrollBar; + const isPointerOverScrollBars = isOverScrollBars( + currentScrollBars, + event.clientX, + event.clientY, + ); + const isOverScrollBar = isPointerOverScrollBars.isOverEither; if (!this.state.draggingElement && !this.state.multiElement) { if (isOverScrollBar) { resetCursor(); @@ -1967,57 +1987,14 @@ class App extends React.Component { return; } - // Handle scrollbars dragging - const isOverScrollBarsNow = isOverScrollBars( - currentScrollBars, - event.clientX, - event.clientY, - ); - const { - isOverHorizontalScrollBar, - isOverVerticalScrollBar, - } = isOverScrollBarsNow; - - const origin = viewportCoordsToSceneCoords( - event, - this.state, - this.canvas, - window.devicePixelRatio, - ); - // State for the duration of a pointer interaction, which starts with a // pointerDown event, ends with a pointerUp event (or another pointerDown) - const pointerDownState: PointerDownState = { - origin, - // we need to duplicate because we'll be updating this state - lastCoords: { ...origin }, - resize: { - handle: false as ReturnType, - isResizing: false, - offset: { x: 0, y: 0 }, - arrowDirection: "origin", - }, - hit: { - element: null, - wasAddedToSelection: false, - }, - }; + const pointerDownState = this.initialPointerDownState(event); - if ( - this.handleDraggingScrollBar(event, pointerDownState, isOverScrollBarsNow) - ) { + if (this.handleDraggingScrollBar(event, pointerDownState)) { return; } - const [originGridX, originGridY] = getGridPoint( - pointerDownState.origin.x, - pointerDownState.origin.y, - this.state.gridSize, - ); - - let draggingOccurred = false; - let dragOffsetXY: [number, number] | null = null; - this.clearSelectionIfNotUsingSelection(); if (this.handleSelectionOnPointerDown(event, pointerDownState)) { @@ -2044,27 +2021,223 @@ class App extends React.Component { ); } - let selectedElementWasDuplicated = false; + const onPointerMove = this.onPointerMoveFromPointerDownHandler( + pointerDownState, + ); + + const onPointerUp = this.onPointerUpFromPointerDownHandler( + pointerDownState, + ); + + lastPointerUp = onPointerUp; + + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.addEventListener(EVENT.POINTER_UP, onPointerUp); + pointerDownState.eventListeners.onMove = onPointerMove; + pointerDownState.eventListeners.onUp = 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) => { - // We need to initialize dragOffsetXY only after we've updated - // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove - // event handler should hopefully ensure we're already working with - // the updated state. - if (dragOffsetXY === null) { - dragOffsetXY = getDragOffsetXY( - getSelectedElements(globalSceneState.getElements(), this.state), - pointerDownState.origin.x, - pointerDownState.origin.y, - ); + 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()), + ); + } + } + + private initialPointerDownState( + event: React.PointerEvent, + ): PointerDownState { + const origin = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + window.devicePixelRatio, + ); + + return { + origin, + originInGrid: tupleToCoors( + getGridPoint(origin.x, origin.y, this.state.gridSize), + ), + scrollbars: isOverScrollBars( + currentScrollBars, + event.clientX, + event.clientY, + ), + // we need to duplicate because we'll be updating this state + lastCoords: { ...origin }, + resize: { + handle: false as ReturnType, + isResizing: false, + offset: { x: 0, y: 0 }, + arrowDirection: "origin", + }, + hit: { + element: null, + wasAddedToSelection: false, + hasBeenDuplicated: false, + }, + drag: { + hasOccurred: false, + offset: null, + }, + eventListeners: { + onMove: null, + onUp: null, + }, + }; + } + + // Returns whether the event is a dragging a scrollbar + private handleDraggingScrollBar( + event: React.PointerEvent, + pointerDownState: PointerDownState, + ): boolean { + if ( + !(pointerDownState.scrollbars.isOverEither && !this.state.multiElement) + ) { + return false; + } + isDraggingScrollBar = true; + pointerDownState.lastCoords.x = event.clientX; + pointerDownState.lastCoords.y = event.clientY; + const onPointerMove = withBatchedUpdates((event: PointerEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } - if (isOverHorizontalScrollBar) { + if (pointerDownState.scrollbars.isOverHorizontal) { const x = event.clientX; const dx = x - pointerDownState.lastCoords.x; this.setState({ @@ -2074,629 +2247,359 @@ class App extends React.Component { return; } - if (isOverVerticalScrollBar) { + if (pointerDownState.scrollbars.isOverVertical) { const y = event.clientY; const dy = y - pointerDownState.lastCoords.y; this.setState({ scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), }); pointerDownState.lastCoords.y = y; - return; } + }); - const { x, y } = viewportCoordsToSceneCoords( - event, - this.state, - this.canvas, - window.devicePixelRatio, - ); - const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize); + 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); + }); - // for arrows/lines, 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 pointermove) - if ( - !draggingOccurred && - (this.state.elementType === "arrow" || - this.state.elementType === "line") - ) { - if ( - distance2d( - x, - y, - pointerDownState.origin.x, - pointerDownState.origin.y, - ) < DRAGGING_THRESHOLD - ) { - return; - } - } + lastPointerUp = onPointerUp; - if (pointerDownState.resize.isResizing) { - const selectedElements = getSelectedElements( - globalSceneState.getElements(), + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.addEventListener(EVENT.POINTER_UP, onPointerUp); + return true; + } + + private clearSelectionIfNotUsingSelection = (): void => { + if (this.state.elementType !== "selection") { + this.setState({ + selectedElementIds: {}, + selectedGroupIds: {}, + editingGroupId: null, + }); + } + }; + + // Returns whether the pointer event has been completely handled + private handleSelectionOnPointerDown = ( + event: React.PointerEvent, + pointerDownState: PointerDownState, + ): boolean => { + if (this.state.elementType === "selection") { + const elements = globalSceneState.getElements(); + const selectedElements = getSelectedElements(elements, this.state); + if (selectedElements.length === 1 && !this.state.editingLinearElement) { + const elementWithResizeHandler = getElementWithResizeHandler( + elements, this.state, + pointerDownState.origin.x, + pointerDownState.origin.y, + this.state.zoom, + event.pointerType, ); - const resizeHandle = pointerDownState.resize.handle; - this.setState({ - // TODO: rename this state field to "isScaling" to distinguish - // it from the generic "isResizing" which includes scaling and - // rotating - isResizing: resizeHandle && resizeHandle !== "rotation", - isRotating: resizeHandle === "rotation", + if (elementWithResizeHandler != null) { + this.setState({ + resizingElement: elementWithResizeHandler.element, + }); + pointerDownState.resize.handle = + elementWithResizeHandler.resizeHandle; + } + } else if (selectedElements.length > 1) { + pointerDownState.resize.handle = getResizeHandlerFromCoords( + getCommonBounds(selectedElements), + pointerDownState.origin.x, + pointerDownState.origin.y, + this.state.zoom, + event.pointerType, + ); + } + if (pointerDownState.resize.handle) { + document.documentElement.style.cursor = getCursorForResizingElement({ + resizeHandle: pointerDownState.resize.handle, }); - const [resizeX, resizeY] = getGridPoint( - x - pointerDownState.resize.offset.x, - y - pointerDownState.resize.offset.y, - this.state.gridSize, + pointerDownState.resize.isResizing = true; + pointerDownState.resize.offset = tupleToCoors( + getResizeOffsetXY( + pointerDownState.resize.handle, + selectedElements, + pointerDownState.origin.x, + pointerDownState.origin.y, + ), ); if ( - resizeElements( - resizeHandle, - (newResizeHandle) => { - pointerDownState.resize.handle = newResizeHandle; - }, - selectedElements, - pointerDownState.resize.arrowDirection, - getRotateWithDiscreteAngleKey(event), - getResizeWithSidesSameLengthKey(event), - getResizeCenterPointKey(event), - resizeX, - resizeY, - ) + selectedElements.length === 1 && + isLinearElement(selectedElements[0]) && + selectedElements[0].points.length === 2 ) { - return; + pointerDownState.resize.arrowDirection = getResizeArrowDirection( + pointerDownState.resize.handle, + selectedElements[0], + ); } - } - - if (this.state.editingLinearElement) { - const didDrag = LinearElementEditor.handlePointDragging( - this.state, - (appState) => this.setState(appState), - x, - y, - pointerDownState.lastCoords.x, - pointerDownState.lastCoords.y, - ); - - if (didDrag) { - pointerDownState.lastCoords.x = x; - pointerDownState.lastCoords.y = y; - return; + } else { + if (this.state.editingLinearElement) { + const ret = LinearElementEditor.handlePointerDown( + event, + this.state, + (appState) => this.setState(appState), + history, + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + if (ret.hitElement) { + pointerDownState.hit.element = ret.hitElement; + } + if (ret.didAddPoint) { + return true; + } } - } - const hitElement = pointerDownState.hit.element; - if (hitElement && this.state.selectedElementIds[hitElement.id]) { - // Marking that click was used for dragging to check - // if elements should be deselected on pointerup - draggingOccurred = true; - const selectedElements = getSelectedElements( - globalSceneState.getElements(), - this.state, - ); - if (selectedElements.length > 0) { - const [dragX, dragY] = getGridPoint( - x - dragOffsetXY[0], - y - dragOffsetXY[1], - this.state.gridSize, + // hitElement may already be set above, so check first + pointerDownState.hit.element = + pointerDownState.hit.element ?? + getElementAtPosition( + elements, + this.state, + pointerDownState.origin.x, + pointerDownState.origin.y, + this.state.zoom, ); - dragSelectedElements(selectedElements, dragX, dragY); - - // We duplicate the selected element if alt is pressed on pointer move - if (event.altKey && !selectedElementWasDuplicated) { - // Move the currently selected elements to the top of the z index stack, and - // put the duplicates where the selected elements used to be. - // (the origin point where the dragging started) - selectedElementWasDuplicated = true; + this.maybeClearSelectionWhenHittingElement( + event, + pointerDownState.hit.element, + ); - const nextElements = []; - const elementsToAppend = []; - const groupIdMap = new Map(); - for (const element of globalSceneState.getElementsIncludingDeleted()) { - if ( - this.state.selectedElementIds[element.id] || - // case: the state.selectedElementIds might not have been - // updated yet by the time this mousemove event is fired - (element.id === hitElement.id && - pointerDownState.hit.wasAddedToSelection) - ) { - const duplicatedElement = duplicateElement( - this.state.editingGroupId, - groupIdMap, - element, - ); - const [originDragX, originDragY] = getGridPoint( - pointerDownState.origin.x - dragOffsetXY[0], - pointerDownState.origin.y - dragOffsetXY[1], - this.state.gridSize, - ); - mutateElement(duplicatedElement, { - x: duplicatedElement.x + (originDragX - dragX), - y: duplicatedElement.y + (originDragY - dragY), - }); - nextElements.push(duplicatedElement); - elementsToAppend.push(element); - } else { - nextElements.push(element); - } + // If we click on something + const hitElement = pointerDownState.hit.element; + if (hitElement != null) { + // deselect if item is selected + // if shift is not clicked, this will always return true + // otherwise, it will trigger selection based on current + // state of the box + if (!this.state.selectedElementIds[hitElement.id]) { + // if we are currently editing a group, treat all selections outside of the group + // as exiting editing mode. + if ( + this.state.editingGroupId && + !isElementInGroup(hitElement, this.state.editingGroupId) + ) { + this.setState({ + selectedElementIds: {}, + selectedGroupIds: {}, + editingGroupId: null, + }); + return true; } - globalSceneState.replaceAllElements([ - ...nextElements, - ...elementsToAppend, - ]); + this.setState((prevState) => { + return selectGroupsForSelectedElements( + { + ...prevState, + selectedElementIds: { + ...prevState.selectedElementIds, + [hitElement!.id]: true, + }, + }, + globalSceneState.getElements(), + ); + }); + // TODO: this is strange... + globalSceneState.replaceAllElements( + globalSceneState.getElementsIncludingDeleted(), + ); + pointerDownState.hit.wasAddedToSelection = true; } - return; } - } - // It is very important to read this.state within each move event, - // otherwise we would read a stale one! - const draggingElement = this.state.draggingElement; - if (!draggingElement) { - return; + const { selectedElementIds } = this.state; + this.setState({ + previousSelectedElementIds: selectedElementIds, + }); } + } + return false; + }; - if (isLinearElement(draggingElement)) { - draggingOccurred = true; - const points = draggingElement.points; - let dx: number; - let dy: number; - if (draggingElement.type === "draw") { - dx = x - draggingElement.x; - dy = y - draggingElement.y; - } else { - dx = gridX - draggingElement.x; - dy = gridY - draggingElement.y; - } - - if (getRotateWithDiscreteAngleKey(event) && points.length === 2) { - ({ width: dx, height: dy } = getPerfectElementSize( - this.state.elementType, - dx, - dy, - )); - } - - if (points.length === 1) { - mutateElement(draggingElement, { points: [...points, [dx, dy]] }); - } else if (points.length > 1) { - if (draggingElement.type === "draw") { - mutateElement(draggingElement, { - points: simplify([...(points as Point[]), [dx, dy]], 0.7), - }); - } else { - mutateElement(draggingElement, { - points: [...points.slice(0, -1), [dx, dy]], - }); - } - } - } else if (draggingElement.type === "selection") { - dragNewElement( - draggingElement, - this.state.elementType, - pointerDownState.origin.x, - pointerDownState.origin.y, - x, - y, - distance(pointerDownState.origin.x, x), - distance(pointerDownState.origin.y, y), - getResizeWithSidesSameLengthKey(event), - getResizeCenterPointKey(event), - ); - } else { - dragNewElement( - draggingElement, - this.state.elementType, - originGridX, - originGridY, - gridX, - gridY, - distance(originGridX, gridX), - distance(originGridY, gridY), - getResizeWithSidesSameLengthKey(event), - getResizeCenterPointKey(event), - ); - } + private handleTextOnPointerDown = ( + event: React.PointerEvent, + pointerDownState: PointerDownState, + ): void => { + // if we're currently still editing text, clicking outside + // should only finalize it, not create another (irrespective + // of state.elementLocked) + if (this.state.editingElement?.type === "text") { + return; + } - if (this.state.elementType === "selection") { - const elements = globalSceneState.getElements(); - if (!event.shiftKey && isSomeElementSelected(elements, this.state)) { - this.setState({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: null, - }); - } - const elementsWithinSelection = getElementsWithinSelection( - elements, - draggingElement, - ); - this.setState((prevState) => - selectGroupsForSelectedElements( - { - ...prevState, - selectedElementIds: { - ...prevState.selectedElementIds, - ...elementsWithinSelection.reduce((map, element) => { - map[element.id] = true; - return map; - }, {} as any), - }, - }, - globalSceneState.getElements(), - ), - ); - } + this.startTextEditing({ + sceneX: pointerDownState.origin.x, + sceneY: pointerDownState.origin.y, + insertAtParentCenter: !event.altKey, }); - const onPointerUp = withBatchedUpdates((childEvent: PointerEvent) => { - const { - draggingElement, - resizingElement, - multiElement, - elementType, - elementLocked, - } = this.state; - + resetCursor(); + if (!this.state.elementLocked) { this.setState({ - isResizing: false, - isRotating: false, - resizingElement: null, - selectionElement: null, - cursorButton: "up", - // text elements are reset on finalize, and resetting on pointerup - // may cause issues with double taps - editingElement: - multiElement || isTextElement(this.state.editingElement) - ? this.state.editingElement - : null, + elementType: "selection", }); + } + }; - this.savePointer(childEvent.clientX, childEvent.clientY, "up"); - - // if moving start/end point towards start/end point within threshold, - // close the loop - if (this.state.editingLinearElement) { - const editingLinearElement = LinearElementEditor.handlePointerUp( - this.state.editingLinearElement, - ); - if (editingLinearElement !== this.state.editingLinearElement) { - this.setState({ editingLinearElement }); - } - } - - lastPointerUp = null; - - window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.removeEventListener(EVENT.POINTER_UP, onPointerUp); - - if (draggingElement?.type === "draw") { - this.actionManager.executeAction(actionFinalize); - return; - } - if (isLinearElement(draggingElement)) { - if (draggingElement!.points.length > 1) { - history.resumeRecording(); - } - if (!draggingOccurred && draggingElement && !multiElement) { - const { x, y } = viewportCoordsToSceneCoords( - childEvent, - this.state, - this.canvas, - window.devicePixelRatio, - ); - mutateElement(draggingElement, { - points: [ - ...draggingElement.points, - [x - draggingElement.x, y - draggingElement.y], - ], - }); - this.setState({ - multiElement: draggingElement, - editingElement: this.state.draggingElement, - }); - } else if (draggingOccurred && !multiElement) { - if (!elementLocked) { - resetCursor(); - this.setState((prevState) => ({ - draggingElement: null, - elementType: "selection", - selectedElementIds: { - ...prevState.selectedElementIds, - [this.state.draggingElement!.id]: true, - }, - })); - } else { - this.setState((prevState) => ({ - draggingElement: null, - selectedElementIds: { - ...prevState.selectedElementIds, - [this.state.draggingElement!.id]: true, - }, - })); - } - } - return; - } + private handleLinearElementOnPointerDown = ( + event: React.PointerEvent, + elementType: "draw" | "line" | "arrow", + pointerDownState: PointerDownState, + ): void => { + if (this.state.multiElement) { + const { multiElement } = this.state; - if ( - elementType !== "selection" && - draggingElement && - isInvisiblySmallElement(draggingElement) - ) { - // remove invisible element which was added in onPointerDown - globalSceneState.replaceAllElements( - globalSceneState.getElementsIncludingDeleted().slice(0, -1), - ); - this.setState({ - draggingElement: null, + // finalize if completing a loop + if (multiElement.type === "line" && isPathALoop(multiElement.points)) { + mutateElement(multiElement, { + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], }); + this.actionManager.executeAction(actionFinalize); return; } - if (draggingElement) { - mutateElement( - draggingElement, - getNormalizedDimensions(draggingElement), - ); - } - - if (resizingElement) { - history.resumeRecording(); - } - - if (resizingElement && isInvisiblySmallElement(resizingElement)) { - globalSceneState.replaceAllElements( - globalSceneState - .getElementsIncludingDeleted() - .filter((el) => el.id !== resizingElement.id), - ); - } + const { x: rx, y: ry, lastCommittedPoint } = multiElement; - // If click occurred on already selected element - // it is needed to remove selection from other elements - // or if SHIFT or META key pressed remove selection - // from hitted element - // - // If click occurred and elements were dragged or some element - // was added to selection (on pointerdown phase) we need to keep - // selection unchanged - const hitElement = pointerDownState.hit.element; + // clicking inside commit zone → finalize arrow if ( - getSelectedGroupIds(this.state).length === 0 && - hitElement && - !draggingOccurred && - !pointerDownState.hit.wasAddedToSelection + multiElement.points.length > 1 && + lastCommittedPoint && + distance2d( + pointerDownState.origin.x - rx, + pointerDownState.origin.y - ry, + lastCommittedPoint[0], + lastCommittedPoint[1], + ) < LINE_CONFIRM_THRESHOLD ) { - if (childEvent.shiftKey) { - this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [hitElement!.id]: false, - }, - })); - } else { - this.setState((_prevState) => ({ - selectedElementIds: { [hitElement!.id]: true }, - })); - } - } - - if (draggingElement === null) { - // if no element is clicked, clear the selection and redraw - this.setState({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: null, - }); + this.actionManager.executeAction(actionFinalize); return; } - if (!elementLocked) { - this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [draggingElement.id]: true, - }, - })); - } - - if ( - elementType !== "selection" || - isSomeElementSelected(globalSceneState.getElements(), this.state) - ) { - history.resumeRecording(); - } - - if (!elementLocked) { - resetCursor(); - this.setState({ - draggingElement: null, - elementType: "selection", - }); - } else { - this.setState({ - draggingElement: null, - }); - } - }); - - lastPointerUp = onPointerUp; - - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); - 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), + this.setState((prevState) => ({ + selectedElementIds: { + ...prevState.selectedElementIds, + [multiElement.id]: true, + }, + })); + // clicking outside commit zone → update reference for last committed + // point + mutateElement(multiElement, { + lastCommittedPoint: multiElement.points[multiElement.points.length - 1], }); - }); - 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; + document.documentElement.style.cursor = CURSOR_TYPE.POINTER; + } else { + const [gridX, gridY] = getGridPoint( + pointerDownState.origin.x, + pointerDownState.origin.y, + elementType === "draw" ? null : this.state.gridSize, + ); + const element = newLinearElement({ + type: elementType, + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + }); + this.setState((prevState) => ({ + selectedElementIds: { + ...prevState.selectedElementIds, + [element.id]: false, + }, + })); + mutateElement(element, { + points: [...element.points, [0, 0]], + }); + globalSceneState.replaceAllElements([ + ...globalSceneState.getElementsIncludingDeleted(), + element, + ]); + this.setState({ + draggingElement: element, + editingElement: element, + }); + } }; - private updateGestureOnPointerDown( - event: React.PointerEvent, - ): void { - gesture.pointers.set(event.pointerId, { - x: event.clientX, - y: event.clientY, + private createGenericElementOnPointerDown = ( + elementType: ExcalidrawGenericElement["type"], + pointerDownState: PointerDownState, + ): void => { + const [gridX, gridY] = getGridPoint( + pointerDownState.origin.x, + pointerDownState.origin.y, + this.state.gridSize, + ); + const element = newElement({ + type: elementType, + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, }); - if (gesture.pointers.size === 2) { - gesture.lastCenter = getCenter(gesture.pointers); - gesture.initialScale = this.state.zoom; - gesture.initialDistance = getDistance( - Array.from(gesture.pointers.values()), - ); + if (element.type === "selection") { + this.setState({ + selectionElement: element, + draggingElement: element, + }); + } else { + globalSceneState.replaceAllElements([ + ...globalSceneState.getElementsIncludingDeleted(), + element, + ]); + this.setState({ + multiElement: null, + draggingElement: element, + editingElement: element, + }); } - } + }; - // Returns whether the event is a dragging a scrollbar - private handleDraggingScrollBar( - event: React.PointerEvent, + private onPointerMoveFromPointerDownHandler( pointerDownState: PointerDownState, - { - isOverHorizontalScrollBar, - isOverVerticalScrollBar, - }: { - isOverHorizontalScrollBar: boolean; - isOverVerticalScrollBar: boolean; - }, - ): boolean { - if ( - !( - (isOverHorizontalScrollBar || isOverVerticalScrollBar) && - !this.state.multiElement - ) - ) { - return false; - } - isDraggingScrollBar = true; - pointerDownState.lastCoords.x = event.clientX; - pointerDownState.lastCoords.y = event.clientY; - const onPointerMove = withBatchedUpdates((event: PointerEvent) => { + ): (event: PointerEvent) => void { + return withBatchedUpdates((event: PointerEvent) => { + // We need to initialize dragOffsetXY only after we've updated + // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove + // event handler should hopefully ensure we're already working with + // the updated state. + if (pointerDownState.drag.offset === null) { + pointerDownState.drag.offset = tupleToCoors( + getDragOffsetXY( + getSelectedElements(globalSceneState.getElements(), this.state), + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + ); + } + const target = event.target; if (!(target instanceof HTMLElement)) { return; } - if (isOverHorizontalScrollBar) { + if (pointerDownState.scrollbars.isOverHorizontal) { const x = event.clientX; const dx = x - pointerDownState.lastCoords.x; this.setState({ @@ -2706,334 +2609,470 @@ class App extends React.Component { return; } - if (isOverVerticalScrollBar) { + if (pointerDownState.scrollbars.isOverVertical) { const y = event.clientY; const dy = y - pointerDownState.lastCoords.y; this.setState({ scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), }); pointerDownState.lastCoords.y = y; + return; } - }); - 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); - }); + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + window.devicePixelRatio, + ); + const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize); - lastPointerUp = onPointerUp; + // for arrows/lines, 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 pointermove) + if ( + !pointerDownState.drag.hasOccurred && + (this.state.elementType === "arrow" || + this.state.elementType === "line") + ) { + if ( + distance2d( + x, + y, + pointerDownState.origin.x, + pointerDownState.origin.y, + ) < DRAGGING_THRESHOLD + ) { + return; + } + } + + if (pointerDownState.resize.isResizing) { + const selectedElements = getSelectedElements( + globalSceneState.getElements(), + this.state, + ); + const resizeHandle = pointerDownState.resize.handle; + this.setState({ + // TODO: rename this state field to "isScaling" to distinguish + // it from the generic "isResizing" which includes scaling and + // rotating + isResizing: resizeHandle && resizeHandle !== "rotation", + isRotating: resizeHandle === "rotation", + }); + const [resizeX, resizeY] = getGridPoint( + x - pointerDownState.resize.offset.x, + y - pointerDownState.resize.offset.y, + this.state.gridSize, + ); + if ( + resizeElements( + resizeHandle, + (newResizeHandle) => { + pointerDownState.resize.handle = newResizeHandle; + }, + selectedElements, + pointerDownState.resize.arrowDirection, + getRotateWithDiscreteAngleKey(event), + getResizeWithSidesSameLengthKey(event), + getResizeCenterPointKey(event), + resizeX, + resizeY, + ) + ) { + return; + } + } + + if (this.state.editingLinearElement) { + const didDrag = LinearElementEditor.handlePointDragging( + this.state, + (appState) => this.setState(appState), + x, + y, + pointerDownState.lastCoords.x, + pointerDownState.lastCoords.y, + ); + + if (didDrag) { + pointerDownState.lastCoords.x = x; + pointerDownState.lastCoords.y = y; + return; + } + } + + const hitElement = pointerDownState.hit.element; + if (hitElement && this.state.selectedElementIds[hitElement.id]) { + // Marking that click was used for dragging to check + // if elements should be deselected on pointerup + pointerDownState.drag.hasOccurred = true; + const selectedElements = getSelectedElements( + globalSceneState.getElements(), + this.state, + ); + if (selectedElements.length > 0) { + const [dragX, dragY] = getGridPoint( + x - pointerDownState.drag.offset.x, + y - pointerDownState.drag.offset.y, + this.state.gridSize, + ); + dragSelectedElements(selectedElements, dragX, dragY); + + // We duplicate the selected element if alt is pressed on pointer move + if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { + // Move the currently selected elements to the top of the z index stack, and + // put the duplicates where the selected elements used to be. + // (the origin point where the dragging started) + + pointerDownState.hit.hasBeenDuplicated = true; + + const nextElements = []; + const elementsToAppend = []; + const groupIdMap = new Map(); + for (const element of globalSceneState.getElementsIncludingDeleted()) { + if ( + this.state.selectedElementIds[element.id] || + // case: the state.selectedElementIds might not have been + // updated yet by the time this mousemove event is fired + (element.id === hitElement.id && + pointerDownState.hit.wasAddedToSelection) + ) { + const duplicatedElement = duplicateElement( + this.state.editingGroupId, + groupIdMap, + element, + ); + const [originDragX, originDragY] = getGridPoint( + pointerDownState.origin.x - pointerDownState.drag.offset.x, + pointerDownState.origin.y - pointerDownState.drag.offset.y, + this.state.gridSize, + ); + mutateElement(duplicatedElement, { + x: duplicatedElement.x + (originDragX - dragX), + y: duplicatedElement.y + (originDragY - dragY), + }); + nextElements.push(duplicatedElement); + elementsToAppend.push(element); + } else { + nextElements.push(element); + } + } + globalSceneState.replaceAllElements([ + ...nextElements, + ...elementsToAppend, + ]); + } + return; + } + } + + // It is very important to read this.state within each move event, + // otherwise we would read a stale one! + const draggingElement = this.state.draggingElement; + if (!draggingElement) { + return; + } - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.addEventListener(EVENT.POINTER_UP, onPointerUp); - return true; - } + if (isLinearElement(draggingElement)) { + pointerDownState.drag.hasOccurred = true; + const points = draggingElement.points; + let dx: number; + let dy: number; + if (draggingElement.type === "draw") { + dx = x - draggingElement.x; + dy = y - draggingElement.y; + } else { + dx = gridX - draggingElement.x; + dy = gridY - draggingElement.y; + } - private clearSelectionIfNotUsingSelection = (): void => { - if (this.state.elementType !== "selection") { - this.setState({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: null, - }); - } - }; + if (getRotateWithDiscreteAngleKey(event) && points.length === 2) { + ({ width: dx, height: dy } = getPerfectElementSize( + this.state.elementType, + dx, + dy, + )); + } - // Returns whether the pointer event has been completely handled - private handleSelectionOnPointerDown = ( - event: React.PointerEvent, - pointerDownState: PointerDownState, - ): boolean => { - if (this.state.elementType === "selection") { - const elements = globalSceneState.getElements(); - const selectedElements = getSelectedElements(elements, this.state); - if (selectedElements.length === 1 && !this.state.editingLinearElement) { - const elementWithResizeHandler = getElementWithResizeHandler( - elements, - this.state, + if (points.length === 1) { + mutateElement(draggingElement, { points: [...points, [dx, dy]] }); + } else if (points.length > 1) { + if (draggingElement.type === "draw") { + mutateElement(draggingElement, { + points: simplify([...(points as Point[]), [dx, dy]], 0.7), + }); + } else { + mutateElement(draggingElement, { + points: [...points.slice(0, -1), [dx, dy]], + }); + } + } + } else if (draggingElement.type === "selection") { + dragNewElement( + draggingElement, + this.state.elementType, pointerDownState.origin.x, pointerDownState.origin.y, - this.state.zoom, - event.pointerType, + x, + y, + distance(pointerDownState.origin.x, x), + distance(pointerDownState.origin.y, y), + getResizeWithSidesSameLengthKey(event), + getResizeCenterPointKey(event), ); - if (elementWithResizeHandler != null) { + } else { + dragNewElement( + draggingElement, + this.state.elementType, + pointerDownState.originInGrid.x, + pointerDownState.originInGrid.y, + gridX, + gridY, + distance(pointerDownState.originInGrid.x, gridX), + distance(pointerDownState.originInGrid.y, gridY), + getResizeWithSidesSameLengthKey(event), + getResizeCenterPointKey(event), + ); + } + + if (this.state.elementType === "selection") { + const elements = globalSceneState.getElements(); + if (!event.shiftKey && isSomeElementSelected(elements, this.state)) { this.setState({ - resizingElement: elementWithResizeHandler.element, + selectedElementIds: {}, + selectedGroupIds: {}, + editingGroupId: null, }); - pointerDownState.resize.handle = - elementWithResizeHandler.resizeHandle; } - } else if (selectedElements.length > 1) { - pointerDownState.resize.handle = getResizeHandlerFromCoords( - getCommonBounds(selectedElements), - pointerDownState.origin.x, - pointerDownState.origin.y, - this.state.zoom, - event.pointerType, + const elementsWithinSelection = getElementsWithinSelection( + elements, + draggingElement, ); - } - if (pointerDownState.resize.handle) { - document.documentElement.style.cursor = getCursorForResizingElement({ - resizeHandle: pointerDownState.resize.handle, - }); - pointerDownState.resize.isResizing = true; - pointerDownState.resize.offset = tupleToCoors( - getResizeOffsetXY( - pointerDownState.resize.handle, - selectedElements, - pointerDownState.origin.x, - pointerDownState.origin.y, + this.setState((prevState) => + selectGroupsForSelectedElements( + { + ...prevState, + selectedElementIds: { + ...prevState.selectedElementIds, + ...elementsWithinSelection.reduce((map, element) => { + map[element.id] = true; + return map; + }, {} as any), + }, + }, + globalSceneState.getElements(), ), ); - if ( - selectedElements.length === 1 && - isLinearElement(selectedElements[0]) && - selectedElements[0].points.length === 2 - ) { - pointerDownState.resize.arrowDirection = getResizeArrowDirection( - pointerDownState.resize.handle, - selectedElements[0], - ); - } - } else { - if (this.state.editingLinearElement) { - const ret = LinearElementEditor.handlePointerDown( - event, - this.state, - (appState) => this.setState(appState), - history, - pointerDownState.origin.x, - pointerDownState.origin.y, - ); - if (ret.hitElement) { - pointerDownState.hit.element = ret.hitElement; - } - if (ret.didAddPoint) { - return true; - } - } + } + }); + } - // hitElement may already be set above, so check first - pointerDownState.hit.element = - pointerDownState.hit.element ?? - getElementAtPosition( - elements, - this.state, - pointerDownState.origin.x, - pointerDownState.origin.y, - this.state.zoom, - ); + private onPointerUpFromPointerDownHandler( + pointerDownState: PointerDownState, + ): (event: PointerEvent) => void { + return withBatchedUpdates((childEvent: PointerEvent) => { + const { + draggingElement, + resizingElement, + multiElement, + elementType, + elementLocked, + } = this.state; - this.maybeClearSelectionWhenHittingElement( - event, - pointerDownState.hit.element, + this.setState({ + isResizing: false, + isRotating: false, + resizingElement: null, + selectionElement: null, + cursorButton: "up", + // text elements are reset on finalize, and resetting on pointerup + // may cause issues with double taps + editingElement: + multiElement || isTextElement(this.state.editingElement) + ? this.state.editingElement + : null, + }); + + this.savePointer(childEvent.clientX, childEvent.clientY, "up"); + + // if moving start/end point towards start/end point within threshold, + // close the loop + if (this.state.editingLinearElement) { + const editingLinearElement = LinearElementEditor.handlePointerUp( + this.state.editingLinearElement, ); + if (editingLinearElement !== this.state.editingLinearElement) { + this.setState({ editingLinearElement }); + } + } - // If we click on something - const hitElement = pointerDownState.hit.element; - if (hitElement != null) { - // deselect if item is selected - // if shift is not clicked, this will always return true - // otherwise, it will trigger selection based on current - // state of the box - if (!this.state.selectedElementIds[hitElement.id]) { - // if we are currently editing a group, treat all selections outside of the group - // as exiting editing mode. - if ( - this.state.editingGroupId && - !isElementInGroup(hitElement, this.state.editingGroupId) - ) { - this.setState({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: null, - }); - return true; - } - this.setState((prevState) => { - return selectGroupsForSelectedElements( - { - ...prevState, - selectedElementIds: { - ...prevState.selectedElementIds, - [hitElement!.id]: true, - }, - }, - globalSceneState.getElements(), - ); - }); - // TODO: this is strange... - globalSceneState.replaceAllElements( - globalSceneState.getElementsIncludingDeleted(), - ); - pointerDownState.hit.wasAddedToSelection = true; + lastPointerUp = null; + + window.removeEventListener( + EVENT.POINTER_MOVE, + pointerDownState.eventListeners.onMove!, + ); + window.removeEventListener( + EVENT.POINTER_UP, + pointerDownState.eventListeners.onUp!, + ); + + if (draggingElement?.type === "draw") { + this.actionManager.executeAction(actionFinalize); + return; + } + if (isLinearElement(draggingElement)) { + if (draggingElement!.points.length > 1) { + history.resumeRecording(); + } + if ( + !pointerDownState.drag.hasOccurred && + draggingElement && + !multiElement + ) { + const { x, y } = viewportCoordsToSceneCoords( + childEvent, + this.state, + this.canvas, + window.devicePixelRatio, + ); + mutateElement(draggingElement, { + points: [ + ...draggingElement.points, + [x - draggingElement.x, y - draggingElement.y], + ], + }); + this.setState({ + multiElement: draggingElement, + editingElement: this.state.draggingElement, + }); + } else if (pointerDownState.drag.hasOccurred && !multiElement) { + if (!elementLocked) { + resetCursor(); + this.setState((prevState) => ({ + draggingElement: null, + elementType: "selection", + selectedElementIds: { + ...prevState.selectedElementIds, + [this.state.draggingElement!.id]: true, + }, + })); + } else { + this.setState((prevState) => ({ + draggingElement: null, + selectedElementIds: { + ...prevState.selectedElementIds, + [this.state.draggingElement!.id]: true, + }, + })); } } + return; + } - const { selectedElementIds } = this.state; + if ( + elementType !== "selection" && + draggingElement && + isInvisiblySmallElement(draggingElement) + ) { + // remove invisible element which was added in onPointerDown + globalSceneState.replaceAllElements( + globalSceneState.getElementsIncludingDeleted().slice(0, -1), + ); this.setState({ - previousSelectedElementIds: selectedElementIds, + draggingElement: null, }); + return; } - } - return false; - }; - private handleTextOnPointerDown = ( - event: React.PointerEvent, - pointerDownState: PointerDownState, - ): void => { - // if we're currently still editing text, clicking outside - // should only finalize it, not create another (irrespective - // of state.elementLocked) - if (this.state.editingElement?.type === "text") { - return; - } + if (draggingElement) { + mutateElement( + draggingElement, + getNormalizedDimensions(draggingElement), + ); + } - this.startTextEditing({ - sceneX: pointerDownState.origin.x, - sceneY: pointerDownState.origin.y, - insertAtParentCenter: !event.altKey, - }); + if (resizingElement) { + history.resumeRecording(); + } - resetCursor(); - if (!this.state.elementLocked) { - this.setState({ - elementType: "selection", - }); - } - }; + if (resizingElement && isInvisiblySmallElement(resizingElement)) { + globalSceneState.replaceAllElements( + globalSceneState + .getElementsIncludingDeleted() + .filter((el) => el.id !== resizingElement.id), + ); + } - private handleLinearElementOnPointerDown = ( - event: React.PointerEvent, - elementType: "draw" | "line" | "arrow", - pointerDownState: PointerDownState, - ): void => { - if (this.state.multiElement) { - const { multiElement } = this.state; + // If click occurred on already selected element + // it is needed to remove selection from other elements + // or if SHIFT or META key pressed remove selection + // from hitted element + // + // If click occurred and elements were dragged or some element + // was added to selection (on pointerdown phase) we need to keep + // selection unchanged + const hitElement = pointerDownState.hit.element; + if ( + getSelectedGroupIds(this.state).length === 0 && + hitElement && + !pointerDownState.drag.hasOccurred && + !pointerDownState.hit.wasAddedToSelection + ) { + if (childEvent.shiftKey) { + this.setState((prevState) => ({ + selectedElementIds: { + ...prevState.selectedElementIds, + [hitElement!.id]: false, + }, + })); + } else { + this.setState((_prevState) => ({ + selectedElementIds: { [hitElement!.id]: true }, + })); + } + } - // finalize if completing a loop - if (multiElement.type === "line" && isPathALoop(multiElement.points)) { - mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + if (draggingElement === null) { + // if no element is clicked, clear the selection and redraw + this.setState({ + selectedElementIds: {}, + selectedGroupIds: {}, + editingGroupId: null, }); - this.actionManager.executeAction(actionFinalize); return; } - const { x: rx, y: ry, lastCommittedPoint } = multiElement; + if (!elementLocked) { + this.setState((prevState) => ({ + selectedElementIds: { + ...prevState.selectedElementIds, + [draggingElement.id]: true, + }, + })); + } - // clicking inside commit zone → finalize arrow if ( - multiElement.points.length > 1 && - lastCommittedPoint && - distance2d( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - lastCommittedPoint[0], - lastCommittedPoint[1], - ) < LINE_CONFIRM_THRESHOLD + elementType !== "selection" || + isSomeElementSelected(globalSceneState.getElements(), this.state) ) { - this.actionManager.executeAction(actionFinalize); - return; + history.resumeRecording(); } - this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [multiElement.id]: true, - }, - })); - // clicking outside commit zone → update reference for last committed - // point - mutateElement(multiElement, { - lastCommittedPoint: multiElement.points[multiElement.points.length - 1], - }); - document.documentElement.style.cursor = CURSOR_TYPE.POINTER; - } else { - const [gridX, gridY] = getGridPoint( - pointerDownState.origin.x, - pointerDownState.origin.y, - elementType === "draw" ? null : this.state.gridSize, - ); - const element = newLinearElement({ - type: elementType, - x: gridX, - y: gridY, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - strokeStyle: this.state.currentItemStrokeStyle, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, - }); - this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [element.id]: false, - }, - })); - mutateElement(element, { - points: [...element.points, [0, 0]], - }); - globalSceneState.replaceAllElements([ - ...globalSceneState.getElementsIncludingDeleted(), - element, - ]); - this.setState({ - draggingElement: element, - editingElement: element, - }); - } - }; - - private createGenericElementOnPointerDown = ( - elementType: ExcalidrawGenericElement["type"], - pointerDownState: PointerDownState, - ): void => { - const [gridX, gridY] = getGridPoint( - pointerDownState.origin.x, - pointerDownState.origin.y, - this.state.gridSize, - ); - const element = newElement({ - type: elementType, - x: gridX, - y: gridY, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - strokeStyle: this.state.currentItemStrokeStyle, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, + if (!elementLocked) { + resetCursor(); + this.setState({ + draggingElement: null, + elementType: "selection", + }); + } else { + this.setState({ + draggingElement: null, + }); + } }); - - if (element.type === "selection") { - this.setState({ - selectionElement: element, - draggingElement: element, - }); - } else { - globalSceneState.replaceAllElements([ - ...globalSceneState.getElementsIncludingDeleted(), - element, - ]); - this.setState({ - multiElement: null, - draggingElement: element, - editingElement: element, - }); - } - }; + } private maybeClearSelectionWhenHittingElement( event: React.PointerEvent, diff --git a/src/scene/scrollbars.ts b/src/scene/scrollbars.ts index d6831257a6..af43b264ef 100644 --- a/src/scene/scrollbars.ts +++ b/src/scene/scrollbars.ts @@ -107,10 +107,11 @@ export const isOverScrollBars = ( x: number, y: number, ): { - isOverHorizontalScrollBar: boolean; - isOverVerticalScrollBar: boolean; + isOverEither: boolean; + isOverHorizontal: boolean; + isOverVertical: boolean; } => { - const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [ + const [isOverHorizontal, isOverVertical] = [ scrollBars.horizontal, scrollBars.vertical, ].map((scrollBar) => { @@ -122,9 +123,6 @@ export const isOverScrollBars = ( y <= scrollBar.y + scrollBar.height ); }); - - return { - isOverHorizontalScrollBar, - isOverVerticalScrollBar, - }; + const isOverEither = isOverHorizontal || isOverVertical; + return { isOverEither, isOverHorizontal, isOverVertical }; };