Hubert Schumacher
Hubert Schumacher

Reputation: 1985

RXJS to react on keyboard/mouse click combination

I'm rather new to RXJS. What I want to achieve is to drag an HtmlElement when the user pushes the space (or any other) key and drags the element with the mouse.

So the start of the dragging is triggered by either SPACE-down_LeftClick or LeftClick_SPACE-down. Both are accepted to ease the usage. The end of the dragging is triggered by either releasing the SPACE key (keyup event) or the mouse click (mouseup event), whichever happens first.

The implementation idea was to have one 'dragstart' observable and one 'dragend' observable, to which I can subscribe to. I have 4 observables corresponding to the events, filtered on the specific key (space) and mouse button (left button) to start with:

spaceDown$ = fromEvent<KeyboardEvent>(document, "keydown").pipe( filter(key => key.code === 'Space'))

spaceUp$ = fromEvent<KeyboardEvent>(document, "keyup").pipe( filter( key => key.code === 'Space'))

mouseDown$ = fromEvent<MouseEvent>(document, "mousedown").pipe( filter( mouse => mouse.button == 0))

mouseUp$ = fromEvent<MouseEvent>(document, "mouseup").pipe( filter( mouse => mouse.button == 0))

What I'm struggling with is how to implement the desired behaviour in RXJS. I've tried with 'combineLatest', 'withLatestFrom', 'merge' different streams, but none of them works as I wish.

Any idea on how to solve this?

Upvotes: 2

Views: 308

Answers (2)

Bastian Br&#228;u
Bastian Br&#228;u

Reputation: 791

I would map the source observables to a bool value, where true would stand for pressed. Then create combined observables for keyboard and mouse. And then again combine those two streams. If both values are true there, it stands for a drag start event, otherwise it's drag end.

const spaceDown$ = fromEvent<KeyboardEvent>(document, 'keydown').pipe(
  filter((key) => key.code === 'Space'),
  map(() => true)
);

const spaceUp$ = fromEvent<KeyboardEvent>(document, 'keyup').pipe(
  filter((key) => key.code === 'Space'),
  map(() => false)
);

const mouseDown$ = fromEvent<MouseEvent>(document, 'mousedown').pipe(
  filter((mouse) => mouse.button == 0),
  map(() => true)
);

const mouseUp$ = fromEvent<MouseEvent>(document, 'mouseup').pipe(
  filter((mouse) => mouse.button == 0),
  map(() => false)
);

const key$ = merge(spaceDown$, spaceUp$).pipe(startWith(false));
const mouse$ = merge(mouseDown$, mouseUp$).pipe(startWith(false));

const combined$ = combineLatest([
  key$,
  mouse$,
]).pipe(
  map(([key, mouse]) => key && mouse),
  distinctUntilChanged(),
);

combined$.subscribe((res) => console.log('combined$', res));

// If different streams are required for dragstart and dragend:

const [dragStart$, dragEnd$] = partition(combined$, (val) => val);

dragStart$.subscribe(() => console.log('dragStart$'));
dragEnd$.subscribe(() => console.log('dragEnd$'));

Upvotes: 2

MGX
MGX

Reputation: 3521

So, you have multiple approaches for that, here are my two takes :

First one : the "dummy" one

In this solution, you use behavior subjects to control the movement, and simply filter on when to listen.

If I did not know well about operators, I would use this approach : it's easy to comprehend and works pretty well.

import {
  BehaviorSubject,
  combineLatest,
  filter,
  fromEvent,
  map,
  Observable,
  switchMap,
  throttleTime,
} from 'rxjs';

const filterSpace = (source: Observable<KeyboardEvent>) =>
  source.pipe(filter((v) => v.code === 'Space'));

const filterLeft = (source: Observable<MouseEvent>) =>
  source.pipe(filter((v) => v.button === 0));

const mousedown$ = fromEvent(document, 'mousedown').pipe(filterLeft);
const mouseup$ = fromEvent(document, 'mouseup').pipe(filterLeft);

const spacedown$ = fromEvent(document, 'keydown').pipe(filterSpace);
const spaceup$ = fromEvent(document, 'keyup').pipe(filterSpace);

const movement$ = fromEvent(document, 'mousemove');

const clicking$ = new BehaviorSubject(false);
const spacing$ = new BehaviorSubject(false);

mousedown$.subscribe(() => clicking$.next(true));
mouseup$.subscribe(() => clicking$.next(false));

spacedown$.subscribe(() => spacing$.next(true));
spaceup$.subscribe(() => spacing$.next(false));

const moving$ = movement$.pipe(
  switchMap((event) =>
    combineLatest([clicking$, spacing$]).pipe(
      filter((conds) => conds.every((v) => !!v)),
      map(() => event)
    )
  )
);

moving$.pipe(throttleTime(100)).subscribe(() => console.log('dragging'));

Second one : the "advanced" one

This one is a bit harder to grasp, but it is what you were asking for I believe.

We start by creating the "starting" listener, which is a combineLatest of the two conditions (click + space). We take the first occurence to close it as soon as it happens, and we repeat it so that we can ... Well, repeat the behavior.

Note : you could also use a filter operator instead, like the previous solution with the .every, but hey, that's a good flex there isn't it ?

Next, we make our "stopping" condition : if either one of the conditions is met, this observable emits.

Unlike the combineLatest, we don't need the first and repeat, because race does not hold a state like combineLatest does : it just emits when one of its members emit.

Finally, we switch our behavior to the actual observable we want to listen to (mouse moving), we listen until our stopping condition is met, and when we complete, we repeat, so that the behavior repeats again.

import {
  combineLatest,
  filter,
  first,
  fromEvent,
  Observable,
  race,
  repeat,
  switchMap,
  takeUntil,
  throttleTime,
} from 'rxjs';

const filterSpace = (source: Observable<KeyboardEvent>) =>
  source.pipe(filter((v) => v.code === 'Space'));

const filterLeft = (source: Observable<MouseEvent>) =>
  source.pipe(filter((v) => v.button === 0));

const mousedown$ = fromEvent(document, 'mousedown').pipe(filterLeft);
const mouseup$ = fromEvent(document, 'mouseup').pipe(filterLeft);

const spacedown$ = fromEvent(document, 'keydown').pipe(filterSpace);
const spaceup$ = fromEvent(document, 'keyup').pipe(filterSpace);

const movement$ = fromEvent(document, 'mousemove');

const starting$ = combineLatest([mousedown$, spacedown$]).pipe(
  first(),
  repeat()
);
const stopping$ = race(mouseup$, spaceup$);

const moving$ = starting$.pipe(
  switchMap(() => movement$),
  takeUntil(stopping$),
  repeat()
);

moving$.pipe(throttleTime(100)).subscribe(() => console.log('dragging'));

Upvotes: 2

Related Questions