license import { Injectable , NgZone , OnDestroy , InjectionToken , Directive } from '@angular/core' ; import { fromEvent , Subject } from 'rxjs' ; import { takeUntil , filter } from 'rxjs/operators' ; import { PointerFocusTracker , FocusableElement } from './pointer-focus-tracker' ; import { Menu } from './menu-interface' ; import { throwMissingPointerFocusTracker , throwMissingMenuReference } from './menu-errors' ; /**
 * MenuAim is responsible for determining if a sibling menuitem's menu should be closed when a
 * Toggler item is hovered into. It is up to the hovered in item to call the MenuAim service in
 * order to determine if it may perform its close actions.
 */ export interface MenuAim { /** Set the Menu and its PointerFocusTracker. */ initialize ( menu , pointerTracker ) : void ; param doToggle toggle ( doToggle ) : void ; } /** Injection token used for an implementation of MenuAim. */ export const MENU_AIM = new InjectionToken < MenuAim > ( 'cdk-menu-aim' ) ; /** Capture every nth mouse move event. */ const MOUSE_MOVE_SAMPLE_FREQUENCY = 3 ; /** The number of mouse move events to track. */ const NUM_POINTS = 5 ; /**
 * How long to wait before closing a sibling menu if a user stops short of the submenu they were
 * predicted to go into.
 */ const CLOSE_DELAY = 300 ; /**
 * An element which when hovered over may perform closing actions on the open submenu and
 * potentially open its own menu.
 */ export interface Toggler { getMenu ( ) : Menu | undefined ; } /** Calculate the slope between point a and b. */ function getSlope ( a , b ) { return ( b . y - a . y ) / ( b . x - a . x ) ; } /** Calculate the y intercept for the given point and slope. */ function getYIntercept ( point , slope ) { return point . y - slope * point . x ; } /** Represents a coordinate of mouse travel. */ type Point = { x : number ; y : number ; } ; param submenuPoints param m param b return function isWithinSubmenu ( submenuPoints , m , b ) { const { left , right , top , bottom } = submenuPoints ; return ( ( m * left + b >= top && m * left + b <= bottom ) || ( m * right + b >= top && m * right + b <= bottom ) || ( ( top - b ) / m >= left && ( top - b ) / m <= right ) || ( ( bottom - b ) / m >= left && ( bottom - b ) / m <= right ) ) ; } /**
 * TargetMenuAim predicts if a user is moving into a submenu. It calculates the
 * trajectory of the user's mouse movement in the current menu to determine if the
 * mouse is moving towards an open submenu.
 *
 * The determination is made by calculating the slope of the users last NUM_POINTS moves where each
 * pair of points determines if the trajectory line points into the submenu. It uses consensus
 * approach by checking if at least NUM_POINTS / 2 pairs determine that the user is moving towards
 * to submenu.
 */ @ Injectable ( ) export class TargetMenuAim implements MenuAim , OnDestroy { /** The last NUM_POINTS mouse move events. */ private readonly _points : Point [ ] = [ ] ; /** Reference to the root menu in which we are tracking mouse moves. */ private _menu : Menu ; /** Reference to the root menu's mouse manager. */ private _pointerTracker : PointerFocusTracker < Toggler & FocusableElement > ; /** The id associated with the current timeout call waiting to resolve. */ private _timeoutId : number | null ; /** Emits when this service is destroyed. */ private readonly _destroyed : Subject < void > = new Subject ( ) ; constructor ( private readonly _ngZone ) { } /** Set the Menu and its PointerFocusTracker. */ initialize ( menu , pointerTracker ) { this . _menu = menu ; this . _pointerTracker = pointerTracker ; this . _subscribeToMouseMoves ( ) ; } param doToggle toggle ( doToggle ) { if ( this . _menu . orientation === 'horizontal' ) { doToggle ( ) ; } this . _checkConfigured ( ) ; const siblingItemIsWaiting = ! ! this . _timeoutId ; const hasPoints = this . _points . length > 1 ; if ( hasPoints && ! siblingItemIsWaiting ) { if ( this . _isMovingToSubmenu ( ) ) { this . _startTimeout ( doToggle ) ; } else { doToggle ( ) ; } } else if ( ! siblingItemIsWaiting ) { doToggle ( ) ; } } /**
     * Start the delayed toggle handler if one isn't running already.
     *
     * The delayed toggle handler executes the `doToggle` callback after some period of time iff the
     * users mouse is on an item in the current menu.
     */ private _startTimeout ( doToggle ) { const timeoutId = setTimeout ( ( ) => { if ( this . _pointerTracker ! . activeElement && timeoutId === this . _timeoutId ) { doToggle ( ) ; } this . _timeoutId = null ; } , CLOSE_DELAY ) as any as number ; this . _timeoutId = timeoutId ; } /** Whether the user is heading towards the open submenu. */ private _isMovingToSubmenu ( ) { const submenuPoints = this . _getSubmenuBounds ( ) ; if ( ! submenuPoints ) { return false ; } let numMoving = 0 ; const currPoint = this . _points [ this . _points . length - 1 ] ; for ( let i = this . _points . length - 2 ; i >= 0 ; i -- ) { const previous = this . _points [ i ] ; const slope = getSlope ( currPoint , previous ) ; if ( isWithinSubmenu ( submenuPoints , slope , getYIntercept ( currPoint , slope ) ) ) { numMoving ++ ; } } return numMoving >= Math . floor ( NUM_POINTS / 2 ) ; } /** Get the bounding DOMRect for the open submenu. */ private _getSubmenuBounds ( ) { return this . _pointerTracker ?. previousElement ?. getMenu ( ) ?. _elementRef . nativeElement . getBoundingClientRect ( ) ; } throws private _checkConfigured ( ) { if ( typeof ngDevMode === 'undefined' || ngDevMode ) { if ( ! this . _pointerTracker ) { throwMissingPointerFocusTracker ( ) ; } if ( ! this . _menu ) { throwMissingMenuReference ( ) ; } } } /** Subscribe to the root menus mouse move events and update the tracked mouse points. */ private _subscribeToMouseMoves ( ) { this . _ngZone . runOutsideAngular ( ( ) => { fromEvent < MouseEvent > ( this . _menu . _elementRef . nativeElement , 'mousemove' ) . pipe ( filter ( ( _ , index ) => index % MOUSE_MOVE_SAMPLE_FREQUENCY === 0 ) , takeUntil ( this . _destroyed ) ) . subscribe ( ( event ) => { this . _points . push ( { x : event . clientX , y : event . clientY } ) ; if ( this . _points . length > NUM_POINTS ) { this . _points . shift ( ) ; } } ) ; } ) ; } ngOnDestroy ( ) { this . _destroyed . next ( ) ; this . _destroyed . complete ( ) ; } } /**
 * CdkTargetMenuAim is a provider for the TargetMenuAim service. It should be added to an
 * element with either the `cdkMenu` or `cdkMenuBar` directive and child menu items.
 */ @ Directive ( { selector : '[cdkTargetMenuAim]' , exportAs : 'cdkTargetMenuAim' , providers : [ { provide : MENU_AIM , useClass : TargetMenuAim } ] , } ) export class CdkTargetMenuAim { }