6
6
* found in the LICENSE file at https://angular.io/license
7
7
*/
8
8
9
- import { Injectable } from '@angular/core' ;
10
- import { Observable , Subject , timer } from 'rxjs' ;
11
- import { audit , distinctUntilChanged , filter , map , share } from 'rxjs/operators' ;
9
+ import { Injectable , NgZone } from '@angular/core' ;
10
+ import { combineLatest , MonoTypeOperatorFunction , Observable , pipe , Subject } from 'rxjs' ;
11
+ import {
12
+ audit ,
13
+ auditTime ,
14
+ debounceTime ,
15
+ distinctUntilChanged ,
16
+ filter ,
17
+ map ,
18
+ share ,
19
+ skip ,
20
+ startWith ,
21
+ } from 'rxjs/operators' ;
12
22
13
23
import { CELL_SELECTOR , ROW_SELECTOR } from './constants' ;
14
24
import { closest } from './polyfill' ;
15
25
import { EditRef } from './edit-ref' ;
16
26
17
- /** The delay between mouse out events and hiding hover content. */
18
- const DEFAULT_MOUSE_OUT_DELAY_MS = 30 ;
27
+ /** The delay applied to mouse events before hiding or showing hover content. */
28
+ const MOUSE_EVENT_DELAY_MS = 40 ;
29
+
30
+ /** The delay for reacting to focus/blur changes. */
31
+ const FOCUS_DELAY = 0 ;
32
+
33
+ /**
34
+ * The possible states for hover content:
35
+ * OFF - Not rendered.
36
+ * FOCUSABLE - Rendered in the dom and stylyed for its contents to be focusable but invisible.
37
+ * ON - Rendered and fully visible.
38
+ */
39
+ export const enum HoverContentState {
40
+ OFF = 0 ,
41
+ FOCUSABLE ,
42
+ ON ,
43
+ }
19
44
20
45
/**
21
46
* Service for sharing delegated events and state for triggering table edits.
@@ -28,7 +53,13 @@ export class EditEventDispatcher {
28
53
/** A subject that indicates which table row is currently hovered. */
29
54
readonly hovering = new Subject < Element | null > ( ) ;
30
55
31
- /** A subject that emits mouse move events for table rows. */
56
+ /** A subject that indicates which table row currently contains focus. */
57
+ readonly focused = new Subject < Element | null > ( ) ;
58
+
59
+ /** A subject that indicates all elements in the table matching ROW_SELECTOR. */
60
+ readonly allRows = new Subject < NodeList > ( ) ;
61
+
62
+ /** A subject that emits mouse move events from the table indicating the targeted row. */
32
63
readonly mouseMove = new Subject < Element | null > ( ) ;
33
64
34
65
/** The EditRef for the currently active edit lens (if any). */
@@ -37,13 +68,76 @@ export class EditEventDispatcher {
37
68
}
38
69
private _editRef : EditRef < any > | null = null ;
39
70
71
+ // Optimization: Precompute common pipeable operators used per row/cell.
72
+ private readonly _distinctUntilChanged =
73
+ distinctUntilChanged < Element | HoverContentState | boolean | null > ( ) ;
74
+ private readonly _startWithNull = startWith < Element | null > ( null ) ;
75
+ private readonly _distinctShare = pipe (
76
+ this . _distinctUntilChanged as MonoTypeOperatorFunction < HoverContentState > ,
77
+ share ( ) ,
78
+ ) ;
79
+ private readonly _startWithNullDistinct = pipe (
80
+ this . _startWithNull ,
81
+ this . _distinctUntilChanged as MonoTypeOperatorFunction < Element | null > ,
82
+ ) ;
83
+
84
+ /** An observable that emits the row containing focus or an active edit. */
85
+ readonly editingOrFocused = combineLatest (
86
+ this . editing . pipe (
87
+ map ( cell => closest ( cell , ROW_SELECTOR ) ) ,
88
+ this . _startWithNull ,
89
+ ) ,
90
+ this . focused . pipe ( this . _startWithNull ) ,
91
+ ) . pipe (
92
+ map ( ( [ editingRow , focusedRow ] ) => focusedRow || editingRow ) ,
93
+ this . _distinctUntilChanged as MonoTypeOperatorFunction < Element | null > ,
94
+ auditTime ( FOCUS_DELAY ) , // Use audit to skip over blur events to the next focused element.
95
+ this . _distinctUntilChanged as MonoTypeOperatorFunction < Element | null > ,
96
+ share ( ) ,
97
+ ) ;
98
+
99
+ /** Tracks rows that contain hover content with a reference count. */
100
+ private _rowsWithHoverContent = new WeakMap < Element , number > ( ) ;
101
+
40
102
/** The table cell that has an active edit lens (or null). */
41
103
private _currentlyEditing : Element | null = null ;
42
104
43
- private readonly _hoveringDistinct = this . hovering . pipe ( distinctUntilChanged ( ) , share ( ) ) ;
44
- private readonly _editingDistinct = this . editing . pipe ( distinctUntilChanged ( ) , share ( ) ) ;
105
+ /** The combined set of row hover content states organized by row. */
106
+ private readonly _hoveredContentStateDistinct = combineLatest (
107
+ this . _getFirstRowWithHoverContent ( ) ,
108
+ this . _getLastRowWithHoverContent ( ) ,
109
+ this . editingOrFocused ,
110
+ this . hovering . pipe (
111
+ distinctUntilChanged ( ) ,
112
+ audit ( row => this . mouseMove . pipe (
113
+ filter ( mouseMoveRow => row === mouseMoveRow ) ,
114
+ this . _startWithNull ,
115
+ debounceTime ( MOUSE_EVENT_DELAY_MS ) ) ,
116
+ ) ,
117
+ this . _startWithNullDistinct ,
118
+ ) ,
119
+ ) . pipe (
120
+ skip ( 1 ) , // Skip the initial emission of [null, null, null, null].
121
+ map ( computeHoverContentState ) ,
122
+ distinctUntilChanged ( areMapEntriesEqual ) ,
123
+ // Optimization: Enter the zone before share() so that we trigger a single
124
+ // ApplicationRef.tick for all row updates.
125
+ this . _enterZone ( ) ,
126
+ share ( ) ,
127
+ ) ;
45
128
46
- constructor ( ) {
129
+ private readonly _editingDistinct = this . editing . pipe (
130
+ distinctUntilChanged ( ) ,
131
+ this . _enterZone ( ) ,
132
+ share ( ) ,
133
+ ) ;
134
+
135
+ // Optimization: Share row events observable with subsequent callers.
136
+ // At startup, calls will be sequential by row.
137
+ private _lastSeenRow : Element | null = null ;
138
+ private _lastSeenRowHoverOrFocus : Observable < HoverContentState > | null = null ;
139
+
140
+ constructor ( private readonly _ngZone : NgZone ) {
47
141
this . _editingDistinct . subscribe ( cell => {
48
142
this . _currentlyEditing = cell ;
49
143
} ) ;
@@ -58,7 +152,7 @@ export class EditEventDispatcher {
58
152
59
153
return this . _editingDistinct . pipe (
60
154
map ( editCell => editCell === ( cell || ( cell = closest ( element , CELL_SELECTOR ) ) ) ) ,
61
- distinctUntilChanged ( ) ,
155
+ this . _distinctUntilChanged as MonoTypeOperatorFunction < boolean > ,
62
156
) ;
63
157
}
64
158
@@ -88,20 +182,123 @@ export class EditEventDispatcher {
88
182
this . _editRef = null ;
89
183
}
90
184
185
+ /** Adds the specified table row to be tracked for first/last row comparisons. */
186
+ registerRowWithHoverContent ( row : Element ) : void {
187
+ this . _rowsWithHoverContent . set ( row , ( this . _rowsWithHoverContent . get ( row ) || 0 ) + 1 ) ;
188
+ }
189
+
190
+ /**
191
+ * Reference decrements and ultimately removes the specified table row from first/last row
192
+ * comparisons.
193
+ */
194
+ deregisterRowWithHoverContent ( row : Element ) : void {
195
+ const refCount = this . _rowsWithHoverContent . get ( row ) || 0 ;
196
+
197
+ if ( refCount <= 1 ) {
198
+ this . _rowsWithHoverContent . delete ( row ) ;
199
+ } else {
200
+ this . _rowsWithHoverContent . set ( row , refCount - 1 ) ;
201
+ }
202
+ }
203
+
91
204
/**
92
205
* Gets an Observable that emits true when the specified element's row
93
- * is being hovered over and false when not. Hovering is defined as when
94
- * the mouse has momentarily stopped moving over the cell.
206
+ * contains the focused element or is being hovered over and false when not.
207
+ * Hovering is defined as when the mouse has momentarily stopped moving over the cell.
208
+ */
209
+ hoverOrFocusOnRow ( row : Element ) : Observable < HoverContentState > {
210
+ if ( row !== this . _lastSeenRow ) {
211
+ this . _lastSeenRow = row ;
212
+ this . _lastSeenRowHoverOrFocus = this . _hoveredContentStateDistinct . pipe (
213
+ map ( state => state . get ( row ) || HoverContentState . OFF ) ,
214
+ this . _distinctShare ,
215
+ ) ;
216
+ }
217
+
218
+ return this . _lastSeenRowHoverOrFocus ! ;
219
+ }
220
+
221
+ /**
222
+ * RxJS operator that enters the Angular zone, used to reduce boilerplate in
223
+ * re-entering the zone for stream pipelines.
95
224
*/
96
- hoveringOnRow ( element : Element | EventTarget ) : Observable < boolean > {
97
- let row : Element | null = null ;
98
-
99
- return this . _hoveringDistinct . pipe (
100
- map ( hoveredRow => hoveredRow === ( row || ( row = closest ( element , ROW_SELECTOR ) ) ) ) ,
101
- audit (
102
- ( hovering ) => hovering ? this . mouseMove . pipe ( filter ( hoveredRow => hoveredRow === row ) ) :
103
- timer ( DEFAULT_MOUSE_OUT_DELAY_MS ) ) ,
104
- distinctUntilChanged ( ) ,
225
+ private _enterZone < T > ( ) : MonoTypeOperatorFunction < T > {
226
+ return ( source : Observable < T > ) =>
227
+ new Observable < T > ( ( observer ) => source . subscribe ( {
228
+ next : ( value ) => this . _ngZone . run ( ( ) => observer . next ( value ) ) ,
229
+ error : ( err ) => observer . error ( err ) ,
230
+ complete : ( ) => observer . complete ( )
231
+ } ) ) ;
232
+ }
233
+
234
+ private _getFirstRowWithHoverContent ( ) : Observable < Element | null > {
235
+ return this . _mapAllRowsToSingleRow ( rows => {
236
+ for ( let i = 0 , row ; row = rows [ i ] ; i ++ ) {
237
+ if ( this . _rowsWithHoverContent . has ( row as Element ) ) {
238
+ return row as Element ;
239
+ }
240
+ }
241
+ return null ;
242
+ } ) ;
243
+ }
244
+
245
+ private _getLastRowWithHoverContent ( ) : Observable < Element | null > {
246
+ return this . _mapAllRowsToSingleRow ( rows => {
247
+ for ( let i = rows . length - 1 , row ; row = rows [ i ] ; i -- ) {
248
+ if ( this . _rowsWithHoverContent . has ( row as Element ) ) {
249
+ return row as Element ;
250
+ }
251
+ }
252
+ return null ;
253
+ } ) ;
254
+ }
255
+
256
+ private _mapAllRowsToSingleRow ( mapper : ( rows : NodeList ) => Element | null ) :
257
+ Observable < Element | null > {
258
+ return this . allRows . pipe (
259
+ map ( mapper ) ,
260
+ this . _startWithNullDistinct ,
105
261
) ;
106
262
}
107
263
}
264
+
265
+ function computeHoverContentState ( [ firstRow , lastRow , activeRow , hoverRow ] : Array < Element | null > ) :
266
+ Map < Element , HoverContentState > {
267
+ const hoverContentState = new Map < Element , HoverContentState > ( ) ;
268
+
269
+ // Add focusable rows.
270
+ for ( const focussableRow of [
271
+ firstRow ,
272
+ lastRow ,
273
+ activeRow && activeRow . previousElementSibling ,
274
+ activeRow && activeRow . nextElementSibling ,
275
+ ] ) {
276
+ if ( focussableRow ) {
277
+ hoverContentState . set ( focussableRow as Element , HoverContentState . FOCUSABLE ) ;
278
+ }
279
+ }
280
+
281
+ // Add/overwrite with fully visible rows.
282
+ for ( const onRow of [ activeRow , hoverRow ] ) {
283
+ if ( onRow ) {
284
+ hoverContentState . set ( onRow , HoverContentState . ON ) ;
285
+ }
286
+ }
287
+
288
+ return hoverContentState ;
289
+ }
290
+
291
+ function areMapEntriesEqual < K , V > ( a : Map < K , V > , b : Map < K , V > ) : boolean {
292
+ if ( a . size !== b . size ) {
293
+ return false ;
294
+ }
295
+
296
+ // TODO: use Map.prototype.entries once we're off IE11.
297
+ for ( const aKey of Array . from ( a . keys ( ) ) ) {
298
+ if ( b . get ( aKey ) !== a . get ( aKey ) ) {
299
+ return false ;
300
+ }
301
+ }
302
+
303
+ return true ;
304
+ }
0 commit comments