From 58286db90c77ca9cc4516c802bcec041cdb2a323 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Thu, 4 Apr 2024 22:50:01 +0100 Subject: [PATCH] Add long press event (#3275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a "long press" event that fires when pointing for more than 500ms. This event is used in the same way that dragging is used (e.g. to transition to from pointing_selection to translating) but only on desktop. On mobile, long presses are used to open the context menu. ![Kapture 2024-03-26 at 18 57 15](https://github.com/tldraw/tldraw/assets/23072548/34a7ee2b-bde6-443b-93e0-082453a1cb61) ## Background This idea came out of @TodePond's #3208 PR. We use a "dead zone" to avoid accidentally moving / rotating things when clicking on them, which is especially common on mobile if a dead zone feature isn't implemented. However, this makes it difficult to make "fine adjustments" because you need to drag out of the dead zone (to start translating) and then drag back to where you want to go. ![Kapture 2024-03-26 at 19 00 38](https://github.com/tldraw/tldraw/assets/23072548/9a15852d-03d0-4b88-b594-27dbd3b68780) With this change, you can long press on desktop to get to that translating state. It's a micro UX optimization but especially nice if apps want to display different UI for "dragging" shapes before the user leaves the dead zone. ![Kapture 2024-03-26 at 19 02 59](https://github.com/tldraw/tldraw/assets/23072548/f0ff337e-2cbd-4b73-9ef5-9b7deaf0ae91) ### Change Type - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [x] `feature` — New feature - [ ] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Long press shapes, selections, resize handles, rotate handles, crop handles. 2. You should enter the corresponding states, just as you would have with a drag. - [ ] Unit Tests TODO ### Release Notes - Add support for long pressing on desktop. --- packages/editor/api-report.md | 6 +- packages/editor/api/api.json | 65 ++++++++++++++++++- packages/editor/src/lib/constants.ts | 3 + packages/editor/src/lib/editor/Editor.ts | 15 ++++- .../editor/src/lib/editor/tools/StateNode.ts | 1 + .../src/lib/editor/types/event-types.ts | 3 + .../childStates/PointingCropHandle.ts | 21 ++++-- .../SelectTool/childStates/PointingHandle.ts | 11 +++- .../childStates/PointingResizeHandle.ts | 15 +++-- .../childStates/PointingRotateHandle.ts | 17 +++-- .../childStates/PointingSelection.ts | 12 +++- .../SelectTool/childStates/PointingShape.ts | 12 +++- .../childStates/ScribbleBrushing.ts | 4 +- 13 files changed, 158 insertions(+), 27 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index c3a7827ab..14c0d41c8 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -1824,6 +1824,8 @@ export abstract class StateNode implements Partial { // (undocumented) onKeyUp?: TLEventHandlers['onKeyUp']; // (undocumented) + onLongPress?: TLEventHandlers['onLongPress']; + // (undocumented) onMiddleClick?: TLEventHandlers['onMiddleClick']; // (undocumented) onPointerDown?: TLEventHandlers['onPointerDown']; @@ -2144,6 +2146,8 @@ export interface TLEventHandlers { // (undocumented) onKeyUp: TLKeyboardEvent; // (undocumented) + onLongPress: TLPointerEvent; + // (undocumented) onMiddleClick: TLPointerEvent; // (undocumented) onPointerDown: TLPointerEvent; @@ -2418,7 +2422,7 @@ export type TLPointerEventInfo = TLBaseEventInfo & { } & TLPointerEventTarget; // @public (undocumented) -export type TLPointerEventName = 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'; +export type TLPointerEventName = 'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'; // @public (undocumented) export type TLPointerEventTarget = { diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 135223a10..b7ebe74b8 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -34940,6 +34940,41 @@ "isProtected": false, "isAbstract": false }, + { + "kind": "Property", + "canonicalReference": "@tldraw/editor!StateNode#onLongPress:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "onLongPress?: " + }, + { + "kind": "Reference", + "text": "TLEventHandlers", + "canonicalReference": "@tldraw/editor!TLEventHandlers:interface" + }, + { + "kind": "Content", + "text": "['onLongPress']" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onLongPress", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false + }, { "kind": "Property", "canonicalReference": "@tldraw/editor!StateNode#onMiddleClick:member", @@ -38355,6 +38390,34 @@ "endIndex": 2 } }, + { + "kind": "PropertySignature", + "canonicalReference": "@tldraw/editor!TLEventHandlers#onLongPress:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "onLongPress: " + }, + { + "kind": "Reference", + "text": "TLPointerEvent", + "canonicalReference": "@tldraw/editor!TLPointerEvent:type" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "onLongPress", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, { "kind": "PropertySignature", "canonicalReference": "@tldraw/editor!TLEventHandlers#onMiddleClick:member", @@ -41004,7 +41067,7 @@ }, { "kind": "Content", - "text": "'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'" + "text": "'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'" }, { "kind": "Content", diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index fbdb79814..43dcbfb10 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -104,3 +104,6 @@ export const COARSE_HANDLE_RADIUS = 20 /** @internal */ export const HANDLE_RADIUS = 12 + +/** @internal */ +export const LONG_PRESS_DURATION = 500 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index d44f2c75e..c05a93365 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -79,6 +79,7 @@ import { FOLLOW_CHASE_ZOOM_UNSNAP, HIT_TEST_MARGIN, INTERNAL_POINTER_IDS, + LONG_PRESS_DURATION, MAX_PAGES, MAX_SHAPES_PER_PAGE, MAX_ZOOM, @@ -8348,6 +8349,9 @@ export class Editor extends EventEmitter { /** @internal */ private _selectedShapeIdsAtPointerDown: TLShapeId[] = [] + /** @internal */ + private _longPressTimeout = -1 as any + /** @internal */ capturedPointerId: number | null = null @@ -8384,8 +8388,8 @@ export class Editor extends EventEmitter { } if (elapsed > 0) { this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) - this.scribbles.tick(elapsed) } + this.scribbles.tick(elapsed) }) } @@ -8450,6 +8454,7 @@ export class Editor extends EventEmitter { switch (type) { case 'pinch': { if (!this.getInstanceState().canMoveCamera) return + clearTimeout(this._longPressTimeout) this._updateInputsFromEvent(info) switch (info.name) { @@ -8574,6 +8579,7 @@ export class Editor extends EventEmitter { (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { + clearTimeout(this._longPressTimeout) inputs.isDragging = true } } @@ -8591,6 +8597,10 @@ export class Editor extends EventEmitter { case 'pointer_down': { this.clearOpenMenus() + this._longPressTimeout = setTimeout(() => { + this.dispatch({ ...info, name: 'long_press' }) + }, LONG_PRESS_DURATION) + this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() // Firefox bug fix... @@ -8659,6 +8669,7 @@ export class Editor extends EventEmitter { (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { + clearTimeout(this._longPressTimeout) inputs.isDragging = true } break @@ -8801,6 +8812,8 @@ export class Editor extends EventEmitter { break } case 'pointer_up': { + clearTimeout(this._longPressTimeout) + const otherEvent = this._clickManager.transformPointerUpEvent(info) if (info.name !== otherEvent.name) { this.root.handleEvent(info) diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index 5c5378b63..f170fe90e 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -198,6 +198,7 @@ export abstract class StateNode implements Partial { onWheel?: TLEventHandlers['onWheel'] onPointerDown?: TLEventHandlers['onPointerDown'] onPointerMove?: TLEventHandlers['onPointerMove'] + onLongPress?: TLEventHandlers['onLongPress'] onPointerUp?: TLEventHandlers['onPointerUp'] onDoubleClick?: TLEventHandlers['onDoubleClick'] onTripleClick?: TLEventHandlers['onTripleClick'] diff --git a/packages/editor/src/lib/editor/types/event-types.ts b/packages/editor/src/lib/editor/types/event-types.ts index 5fa41deb8..89ab5725e 100644 --- a/packages/editor/src/lib/editor/types/event-types.ts +++ b/packages/editor/src/lib/editor/types/event-types.ts @@ -16,6 +16,7 @@ export type TLPointerEventTarget = export type TLPointerEventName = | 'pointer_down' | 'pointer_move' + | 'long_press' | 'pointer_up' | 'right_click' | 'middle_click' @@ -152,6 +153,7 @@ export type TLExitEventHandler = (info: any, to: string) => void export interface TLEventHandlers { onPointerDown: TLPointerEvent onPointerMove: TLPointerEvent + onLongPress: TLPointerEvent onRightClick: TLPointerEvent onDoubleClick: TLClickEvent onTripleClick: TLClickEvent @@ -176,6 +178,7 @@ export const EVENT_NAME_MAP: Record< wheel: 'onWheel', pointer_down: 'onPointerDown', pointer_move: 'onPointerMove', + long_press: 'onLongPress', pointer_up: 'onPointerUp', right_click: 'onRightClick', middle_click: 'onMiddleClick', diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts index c17db4fc2..aefbbdc7d 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts @@ -37,16 +37,23 @@ export class PointingCropHandle extends StateNode { } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { - const isDragging = this.editor.inputs.isDragging - - if (isDragging) { - this.parent.transition('cropping', { - ...this.info, - onInteractionEnd: this.info.onInteractionEnd, - }) + if (this.editor.inputs.isDragging) { + this.startCropping() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startCropping() + } + + private startCropping() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('cropping', { + ...this.info, + onInteractionEnd: this.info.onInteractionEnd, + }) + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts index c199dc9e8..2d8220841 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts @@ -37,10 +37,19 @@ export class PointingHandle extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = () => { if (this.editor.inputs.isDragging) { - this.parent.transition('dragging_handle', this.info) + this.startDraggingHandle() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startDraggingHandle() + } + + private startDraggingHandle() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('dragging_handle', this.info) + } + override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts index 477306814..e353d50ea 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts @@ -48,13 +48,20 @@ export class PointingResizeHandle extends StateNode { } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { - const isDragging = this.editor.inputs.isDragging - - if (isDragging) { - this.parent.transition('resizing', this.info) + if (this.editor.inputs.isDragging) { + this.startResizing() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startResizing() + } + + private startResizing() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('resizing', this.info) + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts index 989d1473d..4053a7764 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts @@ -33,14 +33,21 @@ export class PointingRotateHandle extends StateNode { ) } - override onPointerMove = () => { - const { isDragging } = this.editor.inputs - - if (isDragging) { - this.parent.transition('rotating', this.info) + override onPointerMove: TLEventHandlers['onPointerMove'] = () => { + if (this.editor.inputs.isDragging) { + this.startRotating() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startRotating() + } + + private startRotating() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('rotating', this.info) + } + override onPointerUp = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts index 14ce18f57..8ac57a1e1 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts @@ -25,11 +25,19 @@ export class PointingSelection extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - if (this.editor.getInstanceState().isReadonly) return - this.parent.transition('translating', info) + this.startTranslating(info) } } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startTranslating(info) + } + + private startTranslating(info: TLPointerEventInfo) { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('translating', info) + } + override onDoubleClick?: TLClickEvent | undefined = (info) => { const hoveredShape = this.editor.getHoveredShape() const hitShape = diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts index 4dad8d5e4..7a08ee826 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts @@ -195,11 +195,19 @@ export class PointingShape extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - if (this.editor.getInstanceState().isReadonly) return - this.parent.transition('translating', info) + this.startTranslating(info) } } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startTranslating(info) + } + + private startTranslating(info: TLPointerEventInfo) { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('translating', info) + } + override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts index 4d869e018..d94a953c5 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts @@ -42,9 +42,7 @@ export class ScribbleBrushing extends StateNode { this.updateScribbleSelection(true) - requestAnimationFrame(() => { - this.editor.updateInstanceState({ brush: null }) - }) + this.editor.updateInstanceState({ brush: null }) } override onExit = () => {