Search code examples
typescriptrxjsobservablemousedownmouseup

Long Press detection with SwitchMap, Race and Timer


I'm trying to get a single Observable that can distinguish between a regular click (0-100ms) and a long press (exactly at 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 eventuelly 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

So far I was able to get close 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 before the longPress event can be emitted, it is still emitting a click event.

So I want to restrict the click event to 0-100ms after the mousedown. If the user holds for 1 second it should immediately emit a long press. My current code only works for the regular click but the long press afterwards is ignored:

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 figure this is because the takeUntil in the second stream of the race unsubscribes the race. How can I prevent the mouseup event from ignoring the first stream in the race and thus still have the long press event emitted?

Any help is greatly appreciated.


Solution

  • Thanks to @Giovanni Londero for pointing me in the right direction and helping me find a solution that works for me!

    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 happy to get some recommendations on how to improve this code.