Add long press event (#3275)

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

<!--  Please select a 'Scope' label ️ -->

- [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

<!--  Please select a 'Type' label ️ -->

- [ ] `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.
This commit is contained in:
Steve Ruiz 2024-04-04 22:50:01 +01:00 committed by GitHub
parent 43edeb09b5
commit 58286db90c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 158 additions and 27 deletions

View file

@ -1824,6 +1824,8 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// (undocumented) // (undocumented)
onKeyUp?: TLEventHandlers['onKeyUp']; onKeyUp?: TLEventHandlers['onKeyUp'];
// (undocumented) // (undocumented)
onLongPress?: TLEventHandlers['onLongPress'];
// (undocumented)
onMiddleClick?: TLEventHandlers['onMiddleClick']; onMiddleClick?: TLEventHandlers['onMiddleClick'];
// (undocumented) // (undocumented)
onPointerDown?: TLEventHandlers['onPointerDown']; onPointerDown?: TLEventHandlers['onPointerDown'];
@ -2144,6 +2146,8 @@ export interface TLEventHandlers {
// (undocumented) // (undocumented)
onKeyUp: TLKeyboardEvent; onKeyUp: TLKeyboardEvent;
// (undocumented) // (undocumented)
onLongPress: TLPointerEvent;
// (undocumented)
onMiddleClick: TLPointerEvent; onMiddleClick: TLPointerEvent;
// (undocumented) // (undocumented)
onPointerDown: TLPointerEvent; onPointerDown: TLPointerEvent;
@ -2418,7 +2422,7 @@ export type TLPointerEventInfo = TLBaseEventInfo & {
} & TLPointerEventTarget; } & TLPointerEventTarget;
// @public (undocumented) // @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) // @public (undocumented)
export type TLPointerEventTarget = { export type TLPointerEventTarget = {

View file

@ -34940,6 +34940,41 @@
"isProtected": false, "isProtected": false,
"isAbstract": 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", "kind": "Property",
"canonicalReference": "@tldraw/editor!StateNode#onMiddleClick:member", "canonicalReference": "@tldraw/editor!StateNode#onMiddleClick:member",
@ -38355,6 +38390,34 @@
"endIndex": 2 "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", "kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLEventHandlers#onMiddleClick:member", "canonicalReference": "@tldraw/editor!TLEventHandlers#onMiddleClick:member",
@ -41004,7 +41067,7 @@
}, },
{ {
"kind": "Content", "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", "kind": "Content",

View file

@ -104,3 +104,6 @@ export const COARSE_HANDLE_RADIUS = 20
/** @internal */ /** @internal */
export const HANDLE_RADIUS = 12 export const HANDLE_RADIUS = 12
/** @internal */
export const LONG_PRESS_DURATION = 500

View file

@ -79,6 +79,7 @@ import {
FOLLOW_CHASE_ZOOM_UNSNAP, FOLLOW_CHASE_ZOOM_UNSNAP,
HIT_TEST_MARGIN, HIT_TEST_MARGIN,
INTERNAL_POINTER_IDS, INTERNAL_POINTER_IDS,
LONG_PRESS_DURATION,
MAX_PAGES, MAX_PAGES,
MAX_SHAPES_PER_PAGE, MAX_SHAPES_PER_PAGE,
MAX_ZOOM, MAX_ZOOM,
@ -8348,6 +8349,9 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */ /** @internal */
private _selectedShapeIdsAtPointerDown: TLShapeId[] = [] private _selectedShapeIdsAtPointerDown: TLShapeId[] = []
/** @internal */
private _longPressTimeout = -1 as any
/** @internal */ /** @internal */
capturedPointerId: number | null = null capturedPointerId: number | null = null
@ -8384,8 +8388,8 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
if (elapsed > 0) { if (elapsed > 0) {
this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) 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<TLEventMap> {
switch (type) { switch (type) {
case 'pinch': { case 'pinch': {
if (!this.getInstanceState().canMoveCamera) return if (!this.getInstanceState().canMoveCamera) return
clearTimeout(this._longPressTimeout)
this._updateInputsFromEvent(info) this._updateInputsFromEvent(info)
switch (info.name) { switch (info.name) {
@ -8574,6 +8579,7 @@ export class Editor extends EventEmitter<TLEventMap> {
(this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) /
this.getZoomLevel() this.getZoomLevel()
) { ) {
clearTimeout(this._longPressTimeout)
inputs.isDragging = true inputs.isDragging = true
} }
} }
@ -8591,6 +8597,10 @@ export class Editor extends EventEmitter<TLEventMap> {
case 'pointer_down': { case 'pointer_down': {
this.clearOpenMenus() this.clearOpenMenus()
this._longPressTimeout = setTimeout(() => {
this.dispatch({ ...info, name: 'long_press' })
}, LONG_PRESS_DURATION)
this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds()
// Firefox bug fix... // Firefox bug fix...
@ -8659,6 +8669,7 @@ export class Editor extends EventEmitter<TLEventMap> {
(this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) /
this.getZoomLevel() this.getZoomLevel()
) { ) {
clearTimeout(this._longPressTimeout)
inputs.isDragging = true inputs.isDragging = true
} }
break break
@ -8801,6 +8812,8 @@ export class Editor extends EventEmitter<TLEventMap> {
break break
} }
case 'pointer_up': { case 'pointer_up': {
clearTimeout(this._longPressTimeout)
const otherEvent = this._clickManager.transformPointerUpEvent(info) const otherEvent = this._clickManager.transformPointerUpEvent(info)
if (info.name !== otherEvent.name) { if (info.name !== otherEvent.name) {
this.root.handleEvent(info) this.root.handleEvent(info)

View file

@ -198,6 +198,7 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
onWheel?: TLEventHandlers['onWheel'] onWheel?: TLEventHandlers['onWheel']
onPointerDown?: TLEventHandlers['onPointerDown'] onPointerDown?: TLEventHandlers['onPointerDown']
onPointerMove?: TLEventHandlers['onPointerMove'] onPointerMove?: TLEventHandlers['onPointerMove']
onLongPress?: TLEventHandlers['onLongPress']
onPointerUp?: TLEventHandlers['onPointerUp'] onPointerUp?: TLEventHandlers['onPointerUp']
onDoubleClick?: TLEventHandlers['onDoubleClick'] onDoubleClick?: TLEventHandlers['onDoubleClick']
onTripleClick?: TLEventHandlers['onTripleClick'] onTripleClick?: TLEventHandlers['onTripleClick']

View file

@ -16,6 +16,7 @@ export type TLPointerEventTarget =
export type TLPointerEventName = export type TLPointerEventName =
| 'pointer_down' | 'pointer_down'
| 'pointer_move' | 'pointer_move'
| 'long_press'
| 'pointer_up' | 'pointer_up'
| 'right_click' | 'right_click'
| 'middle_click' | 'middle_click'
@ -152,6 +153,7 @@ export type TLExitEventHandler = (info: any, to: string) => void
export interface TLEventHandlers { export interface TLEventHandlers {
onPointerDown: TLPointerEvent onPointerDown: TLPointerEvent
onPointerMove: TLPointerEvent onPointerMove: TLPointerEvent
onLongPress: TLPointerEvent
onRightClick: TLPointerEvent onRightClick: TLPointerEvent
onDoubleClick: TLClickEvent onDoubleClick: TLClickEvent
onTripleClick: TLClickEvent onTripleClick: TLClickEvent
@ -176,6 +178,7 @@ export const EVENT_NAME_MAP: Record<
wheel: 'onWheel', wheel: 'onWheel',
pointer_down: 'onPointerDown', pointer_down: 'onPointerDown',
pointer_move: 'onPointerMove', pointer_move: 'onPointerMove',
long_press: 'onLongPress',
pointer_up: 'onPointerUp', pointer_up: 'onPointerUp',
right_click: 'onRightClick', right_click: 'onRightClick',
middle_click: 'onMiddleClick', middle_click: 'onMiddleClick',

View file

@ -37,16 +37,23 @@ export class PointingCropHandle extends StateNode {
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const isDragging = this.editor.inputs.isDragging if (this.editor.inputs.isDragging) {
this.startCropping()
if (isDragging) {
this.parent.transition('cropping', {
...this.info,
onInteractionEnd: this.info.onInteractionEnd,
})
} }
} }
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'] = () => { override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)

View file

@ -37,10 +37,19 @@ export class PointingHandle extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
if (this.editor.inputs.isDragging) { 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'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {
this.cancel() this.cancel()
} }

View file

@ -48,13 +48,20 @@ export class PointingResizeHandle extends StateNode {
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const isDragging = this.editor.inputs.isDragging if (this.editor.inputs.isDragging) {
this.startResizing()
if (isDragging) {
this.parent.transition('resizing', this.info)
} }
} }
override onLongPress: TLEventHandlers['onLongPress'] = () => {
this.startResizing()
}
private startResizing() {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('resizing', this.info)
}
override onPointerUp: TLEventHandlers['onPointerUp'] = () => { override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.complete() this.complete()
} }

View file

@ -33,14 +33,21 @@ export class PointingRotateHandle extends StateNode {
) )
} }
override onPointerMove = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
const { isDragging } = this.editor.inputs if (this.editor.inputs.isDragging) {
this.startRotating()
if (isDragging) {
this.parent.transition('rotating', this.info)
} }
} }
override onLongPress: TLEventHandlers['onLongPress'] = () => {
this.startRotating()
}
private startRotating() {
if (this.editor.getInstanceState().isReadonly) return
this.parent.transition('rotating', this.info)
}
override onPointerUp = () => { override onPointerUp = () => {
this.complete() this.complete()
} }

View file

@ -25,11 +25,19 @@ export class PointingSelection extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) { if (this.editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return this.startTranslating(info)
this.parent.transition('translating', 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) => { override onDoubleClick?: TLClickEvent | undefined = (info) => {
const hoveredShape = this.editor.getHoveredShape() const hoveredShape = this.editor.getHoveredShape()
const hitShape = const hitShape =

View file

@ -195,11 +195,19 @@ export class PointingShape extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) { if (this.editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return this.startTranslating(info)
this.parent.transition('translating', 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'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {
this.cancel() this.cancel()
} }

View file

@ -42,9 +42,7 @@ export class ScribbleBrushing extends StateNode {
this.updateScribbleSelection(true) this.updateScribbleSelection(true)
requestAnimationFrame(() => { this.editor.updateInstanceState({ brush: null })
this.editor.updateInstanceState({ brush: null })
})
} }
override onExit = () => { override onExit = () => {