UNPKG

material-motion

Version:

Makes it easy to add rich, interactive motion to your application.

296 lines (246 loc) 9.43 kB
/** @license * Copyright 2016 - present The Material Motion Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy * of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ import { not, when, } from '../aggregators'; import { Axis, GestureRecognitionState, State, } from '../enums'; import { MemorylessMotionSubject, MotionObservable, MotionProperty, createProperty, } from '../observables'; import { isPointerEvent, } from '../typeGuards'; import { ObservableWithMotionOperators, Observer, PartialPointerEvent, Point2D, PointerEventStreams, Subscription, } from '../types'; export class Draggable { readonly state$: MotionProperty<State> = createProperty<State>({ initialValue: State.AT_REST, }); get state(): State { return this.state$.read(); } readonly recognitionState$: MotionProperty<GestureRecognitionState> = createProperty<GestureRecognitionState>({ initialValue: GestureRecognitionState.POSSIBLE, }); get recognitionState(): GestureRecognitionState { return this.recognitionState$.read(); } readonly recognitionThreshold$: MotionProperty<number> = createProperty<number>({ initialValue: 16, }); get recognitionThreshold(): number { return this.recognitionThreshold$.read(); } set recognitionThreshold(value: number) { this.recognitionThreshold$.write(value); } readonly axis$: MotionProperty<Axis> = createProperty<Axis>({ initialValue: Axis.ALL, }); get axis(): Axis { return this.axis$.read(); } set axis(value: Axis) { this.axis$.write(value); } readonly cancellation$: MemorylessMotionSubject<any> = new MemorylessMotionSubject(); cancel(): void { this.cancellation$.next(undefined); } readonly enabled$: MemorylessMotionSubject<boolean> = new MemorylessMotionSubject(); enable(): void { this.enabled$.next(true); } disable(): void { this.enabled$.next(false); } readonly value$: ObservableWithMotionOperators<Point2D>; readonly down$: ObservableWithMotionOperators<PartialPointerEvent>; readonly move$: ObservableWithMotionOperators<PartialPointerEvent>; readonly up$: ObservableWithMotionOperators<PartialPointerEvent>; readonly cancel$: ObservableWithMotionOperators<PartialPointerEvent>; readonly contextMenu$: ObservableWithMotionOperators<PartialPointerEvent>; readonly capturedClick$: ObservableWithMotionOperators<MouseEvent>; readonly capturedDragStart$: ObservableWithMotionOperators<DragEvent>; constructor({ down$, move$, up$, cancel$, contextMenu$, capturedClick$, capturedDragStart$ }: PointerEventStreams) { this.down$ = down$; this.move$ = move$; this.up$ = up$; this.cancel$ = cancel$; this.contextMenu$ = contextMenu$; this.capturedClick$ = capturedClick$; this.capturedDragStart$ = capturedDragStart$; this.value$ = new MotionObservable<Point2D>( (observer: Observer<Point2D>) => { let downSubscription: Subscription | undefined; let moveSubscription: Subscription | undefined; let cancellationSubscription: Subscription | undefined; let enabledSubscription: Subscription | undefined; // If we've recognized a drag, we'll prevent any children from receiving // clicks. let preventClicks: boolean = false; // HTML's OS-integrated drag-and-drop will interrupt a PointerEvent stream // without emitting pointercancel; this is the best way I've found to // prevent that. // // See also https://github.com/w3c/pointerevents/issues/205 const capturedDragStartSubscription: Subscription = capturedDragStart$.subscribe( (dragStartEvent: DragEvent) => { dragStartEvent.preventDefault(); } ); const capturedClickSubscription: Subscription = capturedClick$.subscribe( (clickEvent: MouseEvent) => { if (preventClicks) { clickEvent.preventDefault(); clickEvent.stopImmediatePropagation(); } } ); const subscribeToDown = () => { return down$.subscribe( (downEvent: PartialPointerEvent) => { this.state$.write(State.ACTIVE); const currentAxis = this.axis$.read(); // If we get a new down event while we're already listening for // moves, ignore it. if (!moveSubscription) { preventClicks = false; if (isPointerEvent(downEvent)) { // The `as Element` is a workaround for // https://github.com/Microsoft/TypeScript/issues/299 (downEvent.target as Element).setPointerCapture(downEvent.pointerId); } moveSubscription = move$.merge([ up$ ])._filter({ predicate: (nextEvent: PartialPointerEvent) => nextEvent.pointerId === downEvent.pointerId }).subscribe( (nextEvent: PartialPointerEvent) => { const atRest = nextEvent.type.includes('up'); const translation = { x: currentAxis !== Axis.Y ? nextEvent.x - downEvent.x : 0, y: currentAxis !== Axis.X ? nextEvent.y - downEvent.y : 0, }; switch (this.recognitionState$.read()) { case GestureRecognitionState.POSSIBLE: if (Math.sqrt(translation.x ** 2 + translation.y ** 2) > this.recognitionThreshold$.read()) { this.recognitionState$.write(GestureRecognitionState.BEGAN); preventClicks = true; } break; case GestureRecognitionState.BEGAN: this.recognitionState$.write(GestureRecognitionState.CHANGED); break; default:break; } if (atRest) { // This would be a takeWhile if we were using an Observable // implementation that supported completion. moveSubscription!.unsubscribe(); moveSubscription = undefined; if (this.recognitionState$.read() === GestureRecognitionState.POSSIBLE) { this.recognitionState$.write(GestureRecognitionState.FAILED); } else { this.recognitionState$.write(GestureRecognitionState.ENDED); } // Doing the simple thing for now and setting AT_REST in up, // but it might be better on a delay to give time for clicks // to happen first. this.state$.write(State.AT_REST); this.recognitionState$.write(GestureRecognitionState.POSSIBLE); } else { observer.next(translation); } } ); } } ); }; downSubscription = subscribeToDown(); enabledSubscription = this.enabled$.subscribe( (enabled) => { if (enabled && !downSubscription) { downSubscription = subscribeToDown(); } else if (!enabled && downSubscription) { downSubscription.unsubscribe(); downSubscription = undefined; preventClicks = false; // moveSubscription handled by cancellation flow } } ); cancellationSubscription = this.cancellation$.merge([ when(not(this.enabled$)), cancel$, contextMenu$, ]).subscribe( () => { if (moveSubscription) { moveSubscription.unsubscribe(); moveSubscription = undefined; this.recognitionState$.write(GestureRecognitionState.CANCELLED); this.state$.write(State.AT_REST); this.recognitionState$.write(GestureRecognitionState.POSSIBLE); } } ); return () => { capturedClickSubscription.unsubscribe(); capturedDragStartSubscription.unsubscribe(); if (downSubscription) { downSubscription.unsubscribe(); } if (moveSubscription) { moveSubscription.unsubscribe(); } if (cancellationSubscription) { cancellationSubscription.unsubscribe(); } if (enabledSubscription) { enabledSubscription.unsubscribe(); } }; } )._multicast(); } } export default Draggable;