Detecting a long press using SwitchMap, Race, and Timer techniques

Is there a way to create a single Observable that can differentiate between a regular click (0-100ms) and a long press (at exactly 1000ms)?

pseudocode

  1. User clicks and holds
  2. mouseup between 0 - 100ms -> emit click
  3. No mouseup until 1000ms -> emit long press
    1. (BONUS): Emit separate event called longPressFinished (click or longPress need to be emitted in any case) after the user eventually performs a mouseup sometime after the long press event

Visual representation
time diagram

reproduction
https://codesandbox.io/s/long-press-p4el0?file=/src/index.ts

I have made some progress using:

interface UIEventResponse {
  type: UIEventType,
  event: UIEvent
}

type UIEventType = 'click' | 'longPress'

import { fromEvent, merge, Observable, race, timer } from "rxjs";
import { map, mergeMap, switchMap, take, takeUntil } from "rxjs/operators";

const clickTimeout = 100
const longPressTimeout = 1000

const mouseDown$ = fromEvent<MouseEvent>(window, "mousedown");
const mouseUp$ = fromEvent<MouseEvent>(window, "mouseup");
const click1$ = merge(mouseDown$).pipe(
  switchMap((event) => {
    return race(
      timer(longPressTimeout).pipe(mapTo(true)),
      mouseUp$.pipe(mapTo(false))
    );
  })
);

However, if the user keeps the button pressed until just before the longPress event can be emitted, it still triggers a click event.

I want to limit the click event to 0-100ms after the mousedown. If the user holds for one second, it should immediately trigger a long press. My current code only registers the regular click and ignores the long press afterwards:

const click2$: Observable<UIEventResponse> = mouseDown$.pipe(
  switchMap((event) => {
    return race<UIEventResponse>(
      timer(longPressTimeout).pipe(
        mapTo({
          type: "longPress",
          event
        })
      ),
      mouseUp$.pipe(
        takeUntil(timer(clickTimeout)),
        mapTo({
          type: "click",
          event
        })
      )
    );
  })
);

I suspect this is due to the takeUntil in the second stream of the race unsubscribing the entire race. How can I ensure the mouseup event does not override the first stream in the race so that the long press event is still triggered?

Any assistance would be greatly appreciated.

Answer №1

While this solution may not be the most elegant, it should assist you in resolving your issue; feel free to refine it to eliminate redundancy.

const click2$: Observable<UIEventResponse> = mouseDown$.pipe(
  switchMap((event) => {
    return race<UIEventResponse>(
      timer(longPressTimeout).pipe(
        mapTo({
          type: "longPress",
          event
        })
      ),
      mouseUp$.pipe(
        mapTo({
          type: "click",
          event
        }),
        timeoutWith(
          clickTimeout,
          mouseUp$.pipe(
            mapTo({
              type: "longPress",
              event
            })
          )
        )
      )
    );
  })
);

Outcome

If you press and release within 100ms, it registers as a click.

If you press and release after 100ms, it is recognized as a long press.

If you press and hold without releasing, after 2000ms it's considered a long press.

Explanation

In this code snippet, I replaced takeUntil(timer(...)) with timeoutWith in the race function, allowing for a specified timeout before treating a mouseUp event as a long press.

I opted for mapTo over map for clarity, although either can be used interchangeably.

NOTE: Ensure that the initial mapTo within the mouseUp$.pipe precedes the timeoutWith, as shown in the example, to avoid always mapping to "click".

Answer №2

It appears that utilizing the zip function might be a helpful approach for addressing this issue.

Below is the code snippet:

// To begin with, two Observables are created to emit mousedown and mouseup events along with timestamps
const mouseDown_1$ = fromEvent<MouseEvent>(window, "mousedown").pipe(
  map((event) => {
    const ts = Date.now();
    return { event, ts };
  })
);
const mouseUp_1$ = fromEvent<MouseEvent>(window, "mouseup").pipe(
  map((event) => {
    const ts = Date.now();
    return { event, ts };
  })
);

// The zip function is then used to create an Observable that emits a tuple when both mouseDown_1$ and mouseUp_1$ trigger
const click3$ = zip(mouseDown_1$, mouseUp_1$).pipe(
  // Next step involves calculating the time difference between the timestamps to determine if it was a click or a longPress
  map(([down, up]) => {
    return up.ts - down.ts < clickTimeout
      ? { event: down.event, type: "click" }
      : { event: down.event, type: "longPress" };
  })
);

Answer №3

A big shoutout to @Giovanni Londero for guiding me in the right direction and assisting me in finding a solution that perfectly fits my needs!

const click$: Observable<UIEventResponse> = mouseDown$.pipe(
  switchMap((event) => {
    return race<UIEventResponse>(
      timer(longPressTimeout).pipe(
        mapTo({
          type: "longPress",
          event
        })
      ),
      mouseUp$.pipe(
        mapTo({
          type: "click",
          event
        }),
        timeoutWith(clickTimeout, mouseUp$.pipe(mapTo(undefined)))
      )
    );
  }),
  filter((val) => !!val)
);

I'm open to any suggestions on how I can enhance this code further.

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Using :global() and custom data attributes to apply styles to dynamically added classes

Currently, I am working on creating a typing game that is reminiscent of monkeytype.com. In this game, every letter is linked to classes that change dynamically from an empty string to either 'correct' or 'incorrect', depending on wheth ...

Update the class attributes to a JSON string encoding the new values

I have created a new class with the following properties: ''' import { Deserializable } from '../deserializable'; export class Outdoor implements Deserializable { ActualTemp: number; TargetTemp: number; Day: number; ...

Firebase is storing object values as 'undefined'

My goal is to retrieve user details from my firebase database while using Ionic and Typescript. Here is how I add a user: addToDatabase(user: User) { let isInstructor = user.isInstructor == null ? false : user.isInstructor; this.afDB.list("/users/").push ...

Tips for preventing VSCode TypeScript files from importing from unauthorized folders?

We work with typescript and webpack in a single repository to develop our game. To ensure shared states and objects, we have organized the code into three main folders. This shared code is utilized on both the backend and frontend. It is crucial that serv ...

TS7016: No declaration file was found for the module named 'rxjs'

I recently updated my Angular App dependencies and successfully installed them. However, I am now facing an issue with 'rxjs'. The IDE returned the following error: TS7016: Could not find a declaration file for module 'rxjs'.'C:/ ...

I am facing an issue with my Angular 11 CLI application while trying to set it up with Jest. The specific error message I am encountering is: "TypeError: Cannot read property

Having issues with my Angular 11 cli app and Jest integration. Whenever I run npm run test, specifically jest --updateSnapshot, I encounter the following error in my terminal: Error: TypeError: Cannot read property 'paths' of undefined Here is ...

Passing data through Angular2 router: a comprehensive guide

I am currently developing a web application with the latest version of Angular (Angular v2.0.0). In my app, I have a sub-navigation and I want to pass data to a sub-page that loads its own component through the router-outlet. According to Angular 2 docume ...

Using the async pipe with the ngIf directive

When the AsyncPipe is used within an *ngIf, there may be issues if the Observable associated with the AsyncPipe emits its values before the *ngIf statement evaluates to true. For instance, consider the following scenario: <div *ngIf="showData"> ...

Angular 4 encounters a hiccup when a mistake in the XHR request brings a halt to a

In my Angular 4 application, I have implemented an observable that monitors an input field. When it detects a URL being entered, it triggers a service to make an XHR request. Observable.fromEvent(this._elementRef.nativeElement, 'input') .debou ...

Converting an array of objects to an array of JSON objects in TypeScript

My dilemma lies in the data I have uploaded under the _attachments variable: https://i.sstatic.net/jnFNH.png My aim is to format this data for insertion in the following structure: "_attachments": [ { "container": "string", "fileName": "string" ...

Ordering an array using Typescript within React's useEffect()

Currently, I am facing a TypeScript challenge with sorting an array of movie objects set in useEffect so that they are displayed alphabetically. While my ultimate goal is to implement various sorting functionalities based on different properties in the fut ...

Steps to display a modal dialog depending on the category of the label

I'm encountering an issue where clicking on different labels should show corresponding modal dialogs, but it always displays the same modal dialog for both labels ("All Recommendations"). Can anyone offer guidance on how to resolve this problem? Thank ...

The Typescript error "Attempting to call a function that does not have any callable signatures.(2349)"

Could you please assist me in resolving this issue: type IValidator = (value?: string) => string | undefined; type IComposeValidators = (validators: ((value?: string) => string | undefined)[]) => IValidator; export const composeValidators: ICompo ...

Ensure the most recently expanded item appears at the top of the TreeView by updating to Mui version

I'm working on a feature where I want to scroll the expanded TreeItem to the top when it has children and is clicked on for expansion. Any suggestions on how to make this happen? ...

Angular 2's JSON tube is malfunctioning

Each time I attempt to utilize the JSON pipe to pass my data, an error message appears in the console: Unhandled Promise rejection: Template parse errors: The pipe 'json' could not be found (... Can someone help me understand what mistake I am ...

Using Typescript for AngularJS bindings with ng.IComponentController

Currently, I am utilizing webpack alongside Babel and Typescript Presently, the controller in question is as follows: // HelloWorldController.ts class HelloWorldController implements ng.IComponentController { constructor(private $scope: ng.IScope) { } ...

What is the best approach to retrieve all user information using React with TypeScript and the Redux Toolkit?

I'm currently using React with TypeScript and Redux Toolkit, but I've hit a roadblock trying to retrieve user information. Below is my userSlice.ts file: export const userSlice = createSlice({ name: "user", initialState: { user: null, } ...

Get a specific attribute of an object instead of directly accessing it

Is there a way to retrieve a specific object property in my checkForUrgentEvents method without referencing it directly? I attempted using Object.hasOwnProperty but it didn't work due to the deep nesting of the object. private checkForUrgentEvents(ur ...

What are the steps for creating a custom repository with TypeORM (MongoDB) in NestJS?

One query that arises is regarding the @EntityRepository decorator becoming deprecated in typeorm@^0.3.6. What is now the recommended or TypeScript-friendly approach to creating a custom repository for an entity in NestJS? Previously, a custom repository w ...

Exclude the key-value pair for any objects where the value is null

Is there a way to omit one key-value pair if the value is null in the TypeScript code snippet below, which creates a new record in the Firestore database? firestore.doc(`users/${user.uid}`).set({ email: user.email, name: user.displayName, phone: ...