d144c81fd3a8fd38816b7257362bd874a6c781da
[sdc/sdc-workflow-designer.git] /
1 /**
2  * @license
3  * Copyright Google Inc. All Rights Reserved.
4  *
5  * Use of this source code is governed by an MIT-style license that can be
6  * found in the LICENSE file at https://angular.io/license
7  */
8 /* tslint:disable:array-type member-access variable-name typedef
9  only-arrow-functions directive-class-suffix component-class-suffix
10  component-selector no-unnecessary-type-assertion arrow-parens
11  no-unused-variable*/
12 import {ElementRef} from '@angular/core';
13 import {Observable} from 'rxjs/Observable';
14 import {Subject} from 'rxjs/Subject';
15
16 import {Scrollable} from '../scroll/scrollable';
17
18 import {ConnectedOverlayPositionChange, ConnectionPositionPair, OriginConnectionPosition, OverlayConnectionPosition, ScrollableViewProperties} from './connected-position';
19 import {PositionStrategy} from './position-strategy';
20 import {ViewportRuler} from './viewport-ruler';
21
22 /**
23  * Container to hold the bounding positions of a particular element with respect
24  * to the viewport, where top and bottom are the y-axis coordinates of the
25  * bounding rectangle and left and right are the x-axis coordinates.
26  */
27 interface ElementBoundingPositions {
28   top: number;
29   right: number;
30   bottom: number;
31   left: number;
32 }
33
34 /**
35  * A strategy for positioning overlays. Using this strategy, an overlay is given
36  * an implicit position relative some origin element. The relative position is
37  * defined in terms of a point on the origin element that is connected to a
38  * point on the overlay element. For example, a basic dropdown is connecting the
39  * bottom-left corner of the origin to the top-left corner of the overlay.
40  */
41 export class ConnectedPositionStrategy implements PositionStrategy {
42   private _dir = 'ltr';
43
44   /** The offset in pixels for the overlay connection point on the x-axis */
45   private _offsetX = 0;
46
47   /** The offset in pixels for the overlay connection point on the y-axis */
48   private _offsetY = 0;
49
50   /** The Scrollable containers used to check scrollable view properties on
51    * position change. */
52   private scrollables: Scrollable[] = [];
53
54   /** Whether the we're dealing with an RTL context */
55   get _isRtl() {
56         return this._dir === 'rtl';
57   }
58
59   /** Ordered list of preferred positions, from most to least desirable. */
60   _preferredPositions: ConnectionPositionPair[] = [];
61
62   /** The origin element against which the overlay will be positioned. */
63   private _origin: HTMLElement;
64
65   /** The overlay pane element. */
66   private _pane: HTMLElement;
67
68   /** The last position to have been calculated as the best fit position. */
69   private _lastConnectedPosition: ConnectionPositionPair;
70
71   _onPositionChange: Subject<ConnectedOverlayPositionChange> =
72                 new Subject<ConnectedOverlayPositionChange>();
73
74   /** Emits an event when the connection point changes. */
75   get onPositionChange(): Observable<ConnectedOverlayPositionChange> {
76         return this._onPositionChange.asObservable();
77   }
78
79   constructor(
80                 private _connectedTo: ElementRef,
81                 private _originPos: OriginConnectionPosition,
82                 private _overlayPos: OverlayConnectionPosition,
83                 private _viewportRuler: ViewportRuler) {
84         this._origin = this._connectedTo.nativeElement;
85         this.withFallbackPosition(this._originPos, this._overlayPos);
86   }
87
88   /** Ordered list of preferred positions, from most to least desirable. */
89   get positions() {
90         return this._preferredPositions;
91   }
92
93   /**
94    * To be used to for any cleanup after the element gets destroyed.
95    */
96   dispose() {
97     //
98   }
99
100   /**
101    * Updates the position of the overlay element, using whichever preferred
102    * position relative to the origin fits on-screen.
103    * @docs-private
104    *
105    * @param element Element to which to apply the CSS styles.
106    * @returns Resolves when the styles have been applied.
107    */
108   apply(element: HTMLElement): void {
109     // Cache the overlay pane element in case re-calculating position is
110     // necessary
111         this._pane = element;
112
113     // We need the bounding rects for the origin and the overlay to determine
114     // how to position the overlay relative to the origin.
115         const originRect = this._origin.getBoundingClientRect();
116         const overlayRect = element.getBoundingClientRect();
117
118     // We use the viewport rect to determine whether a position would go
119     // off-screen.
120         const viewportRect = this._viewportRuler.getViewportRect();
121
122     // Fallback point if none of the fallbacks fit into the viewport.
123         let fallbackPoint: OverlayPoint|undefined;
124         let fallbackPosition: ConnectionPositionPair|undefined;
125
126     // We want to place the overlay in the first of the preferred positions such
127     // that the overlay fits on-screen.
128         for (const pos of this._preferredPositions) {
129       // Get the (x, y) point of connection on the origin, and then use that to
130       // get the (top, left) coordinate for the overlay at `pos`.
131                 const originPoint = this._getOriginConnectionPoint(originRect, pos);
132                 const overlayPoint =
133                         this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos);
134
135       // If the overlay in the calculated position fits on-screen, put it there
136       // and we're done.
137                 if (overlayPoint.fitsInViewport) {
138                 this._setElementPosition(element, overlayRect, overlayPoint, pos);
139
140         // Save the last connected position in case the position needs to be
141         // re-calculated.
142                 this._lastConnectedPosition = pos;
143
144         // Notify that the position has been changed along with its change
145         // properties.
146                 const scrollableViewProperties =
147                         this.getScrollableViewProperties(element);
148                 const positionChange =
149                         new ConnectedOverlayPositionChange(pos, scrollableViewProperties);
150                 this._onPositionChange.next(positionChange);
151
152                 return;
153                 } else if (
154                         !fallbackPoint ||
155                         fallbackPoint.visibleArea < overlayPoint.visibleArea) {
156                 fallbackPoint = overlayPoint;
157                 fallbackPosition = pos;
158                 }
159         }
160
161     // If none of the preferred positions were in the viewport, take the one
162     // with the largest visible area.
163         this._setElementPosition(
164                 element, overlayRect, fallbackPoint, fallbackPosition);
165   }
166
167   /**
168    * This re-aligns the overlay element with the trigger in its last calculated
169    * position, even if a position higher in the "preferred positions" list would
170    * now fit. This allows one to re-align the panel without changing the
171    * orientation of the panel.
172    */
173   recalculateLastPosition(): void {
174         const originRect = this._origin.getBoundingClientRect();
175         const overlayRect = this._pane.getBoundingClientRect();
176         const viewportRect = this._viewportRuler.getViewportRect();
177         const lastPosition =
178                 this._lastConnectedPosition || this._preferredPositions[0];
179
180         const originPoint =
181                 this._getOriginConnectionPoint(originRect, lastPosition);
182         const overlayPoint = this._getOverlayPoint(
183                 originPoint, overlayRect, viewportRect, lastPosition);
184         this._setElementPosition(
185                 this._pane, overlayRect, overlayPoint, lastPosition);
186   }
187
188   /**
189    * Sets the list of Scrollable containers that host the origin element so that
190    * on reposition we can evaluate if it or the overlay has been clipped or
191    * outside view. Every Scrollable must be an ancestor element of the
192    * strategy's origin element.
193    */
194   withScrollableContainers(scrollables: Scrollable[]) {
195         this.scrollables = scrollables;
196   }
197
198   /**
199    * Adds a new preferred fallback position.
200    * @param originPos
201    * @param overlayPos
202    */
203   withFallbackPosition(
204                 originPos: OriginConnectionPosition,
205                 overlayPos: OverlayConnectionPosition): this {
206         this._preferredPositions.push(
207                 new ConnectionPositionPair(originPos, overlayPos));
208         return this;
209   }
210
211   /**
212    * Sets the layout direction so the overlay's position can be adjusted to
213    * match.
214    * @param dir New layout direction.
215    */
216   withDirection(dir: 'ltr'|'rtl'): this {
217         this._dir = dir;
218         return this;
219   }
220
221   /**
222    * Sets an offset for the overlay's connection point on the x-axis
223    * @param offset New offset in the X axis.
224    */
225   withOffsetX(offset: number): this {
226         this._offsetX = offset;
227         return this;
228   }
229
230   /**
231    * Sets an offset for the overlay's connection point on the y-axis
232    * @param  offset New offset in the Y axis.
233    */
234   withOffsetY(offset: number): this {
235         this._offsetY = offset;
236         return this;
237   }
238
239   /**
240    * Gets the horizontal (x) "start" dimension based on whether the overlay is
241    * in an RTL context.
242    * @param rect
243    */
244   private _getStartX(rect: ClientRect): number {
245         return this._isRtl ? rect.right : rect.left;
246   }
247
248   /**
249    * Gets the horizontal (x) "end" dimension based on whether the overlay is in
250    * an RTL context.
251    * @param rect
252    */
253   private _getEndX(rect: ClientRect): number {
254         return this._isRtl ? rect.left : rect.right;
255   }
256
257
258   /**
259    * Gets the (x, y) coordinate of a connection point on the origin based on a
260    * relative position.
261    * @param originRect
262    * @param pos
263    */
264   private _getOriginConnectionPoint(
265                 originRect: ClientRect, pos: ConnectionPositionPair): Point {
266         const originStartX = this._getStartX(originRect);
267         const originEndX = this._getEndX(originRect);
268
269         let x: number;
270         if (pos.originX === 'center') {
271                 x = originStartX + (originRect.width / 2);
272         } else {
273                 x = pos.originX === 'start' ? originStartX : originEndX;
274         }
275
276         let y: number;
277         if (pos.originY === 'center') {
278                 y = originRect.top + (originRect.height / 2);
279         } else {
280                 y = pos.originY === 'top' ? originRect.top : originRect.bottom;
281         }
282
283         return {x, y};
284   }
285
286
287   /**
288    * Gets the (x, y) coordinate of the top-left corner of the overlay given a
289    * given position and origin point to which the overlay should be connected,
290    * as well as how much of the element would be inside the viewport at that
291    * position.
292    */
293   private _getOverlayPoint(
294                 originPoint: Point, overlayRect: ClientRect, viewportRect: ClientRect,
295                 pos: ConnectionPositionPair): OverlayPoint {
296     // Calculate the (overlayStartX, overlayStartY), the start of the potential
297     // overlay position relative to the origin point.
298         let overlayStartX: number;
299         if (pos.overlayX === 'center') {
300                 overlayStartX = -overlayRect.width / 2;
301         } else if (pos.overlayX === 'start') {
302                 overlayStartX = this._isRtl ? -overlayRect.width : 0;
303         } else {
304                 overlayStartX = this._isRtl ? 0 : -overlayRect.width;
305         }
306
307         let overlayStartY: number;
308         if (pos.overlayY === 'center') {
309                 overlayStartY = -overlayRect.height / 2;
310         } else {
311                 overlayStartY = pos.overlayY === 'top' ? 0 : -overlayRect.height;
312         }
313
314     // The (x, y) coordinates of the overlay.
315         const x = originPoint.x + overlayStartX + this._offsetX;
316         const y = originPoint.y + overlayStartY + this._offsetY;
317
318     // How much the overlay would overflow at this position, on each side.
319         const leftOverflow = 0 - x;
320         const rightOverflow = (x + overlayRect.width) - viewportRect.width;
321         const topOverflow = 0 - y;
322         const bottomOverflow = (y + overlayRect.height) - viewportRect.height;
323
324     // Visible parts of the element on each axis.
325         const visibleWidth =
326                 this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow);
327         const visibleHeight = this._subtractOverflows(
328                 overlayRect.height, topOverflow, bottomOverflow);
329
330     // The area of the element that's within the viewport.
331         const visibleArea = visibleWidth * visibleHeight;
332         const fitsInViewport =
333                 (overlayRect.width * overlayRect.height) === visibleArea;
334
335         return {x, y, fitsInViewport, visibleArea};
336   }
337
338   /**
339    * Gets the view properties of the trigger and overlay, including whether they
340    * are clipped or completely outside the view of any of the strategy's
341    * scrollables.
342    */
343   private getScrollableViewProperties(overlay: HTMLElement):
344                 ScrollableViewProperties {
345         const originBounds = this._getElementBounds(this._origin);
346         const overlayBounds = this._getElementBounds(overlay);
347         const scrollContainerBounds =
348                 this.scrollables.map((scrollable: Scrollable) => {
349                         return this._getElementBounds(
350                                 scrollable.getElementRef().nativeElement);
351                 });
352
353         return {
354                 isOriginClipped:
355                         this.isElementClipped(originBounds, scrollContainerBounds),
356                 isOriginOutsideView:
357                         this.isElementOutsideView(originBounds, scrollContainerBounds),
358                 isOverlayClipped:
359                         this.isElementClipped(overlayBounds, scrollContainerBounds),
360                 isOverlayOutsideView:
361                         this.isElementOutsideView(overlayBounds, scrollContainerBounds),
362         };
363   }
364
365   /** Whether the element is completely out of the view of any of the
366    * containers. */
367   private isElementOutsideView(
368                 elementBounds: ElementBoundingPositions,
369                 containersBounds: ElementBoundingPositions[]): boolean {
370         return containersBounds.some(
371                 (containerBounds: ElementBoundingPositions) => {
372                         const outsideAbove = elementBounds.bottom < containerBounds.top;
373                         const outsideBelow = elementBounds.top > containerBounds.bottom;
374                         const outsideLeft = elementBounds.right < containerBounds.left;
375                         const outsideRight = elementBounds.left > containerBounds.right;
376
377                         return outsideAbove || outsideBelow || outsideLeft || outsideRight;
378                 });
379   }
380
381   /** Whether the element is clipped by any of the containers. */
382   private isElementClipped(
383                 elementBounds: ElementBoundingPositions,
384                 containersBounds: ElementBoundingPositions[]): boolean {
385         return containersBounds.some(
386                 (containerBounds: ElementBoundingPositions) => {
387                         const clippedAbove = elementBounds.top < containerBounds.top;
388                         const clippedBelow = elementBounds.bottom > containerBounds.bottom;
389                         const clippedLeft = elementBounds.left < containerBounds.left;
390                         const clippedRight = elementBounds.right > containerBounds.right;
391
392                         return clippedAbove || clippedBelow || clippedLeft || clippedRight;
393                 });
394   }
395
396   /** Physically positions the overlay element to the given coordinate. */
397   private _setElementPosition(
398                 element: HTMLElement, overlayRect: ClientRect, overlayPoint: Point,
399                 pos: ConnectionPositionPair) {
400     // We want to set either `top` or `bottom` based on whether the overlay
401     // wants to appear above or below the origin and the direction in which the
402     // element will expand.
403         const verticalStyleProperty = pos.overlayY === 'bottom' ? 'bottom' : 'top';
404
405     // When using `bottom`, we adjust the y position such that it is the
406     // distance from the bottom of the viewport rather than the top.
407         const y = verticalStyleProperty === 'top' ?
408                 overlayPoint.y :
409                 document.documentElement.clientHeight -
410                         (overlayPoint.y + overlayRect.height);
411
412     // We want to set either `left` or `right` based on whether the overlay
413     // wants to appear "before" or "after" the origin, which determines the
414     // direction in which the element will expand. For the horizontal axis, the
415     // meaning of "before" and "after" change based on whether the page is in
416     // RTL or LTR.
417         let horizontalStyleProperty: any;
418         if (this._dir === 'rtl') {
419                 horizontalStyleProperty = pos.overlayX === 'end' ? 'left' : 'right';
420         } else {
421                 horizontalStyleProperty = pos.overlayX === 'end' ? 'right' : 'left';
422         }
423
424     // When we're setting `right`, we adjust the x position such that it is the
425     // distance from the right edge of the viewport rather than the left edge.
426         const x = horizontalStyleProperty === 'left' ?
427                 overlayPoint.x :
428                 document.documentElement.clientWidth -
429                         (overlayPoint.x + overlayRect.width);
430
431
432     // Reset any existing styles. This is necessary in case the preferred
433     // position has changed since the last `apply`.
434         ['top', 'bottom', 'left', 'right'].forEach(
435                 (p: any) => element.style[p] = null);
436
437         element.style[verticalStyleProperty] = `${y}px`;
438         element.style[horizontalStyleProperty] = `${x}px`;
439   }
440
441   /** Returns the bounding positions of the provided element with respect to the
442    * viewport. */
443   private _getElementBounds(element: HTMLElement): ElementBoundingPositions {
444         const boundingClientRect = element.getBoundingClientRect();
445         return {
446                 top: boundingClientRect.top,
447                 right: boundingClientRect.left + boundingClientRect.width,
448                 bottom: boundingClientRect.top + boundingClientRect.height,
449                 left: boundingClientRect.left
450         };
451   }
452
453   /**
454    * Subtracts the amount that an element is overflowing on an axis from it's
455    * length.
456    */
457   private _subtractOverflows(length: number, ...overflows: number[]): number {
458         return overflows.reduce((currentValue: number, currentOverflow: number) => {
459                 return currentValue - Math.max(currentOverflow, 0);
460         }, length);
461   }
462 }
463
464 /** A simple (x, y) coordinate. */
465 interface Point {
466   x: number;
467   y: number;
468 }
469
470 /**
471  * Expands the simple (x, y) coordinate by adding info about whether the
472  * element would fit inside the viewport at that position, as well as
473  * how much of the element would be visible.
474  */
475 interface OverlayPoint extends Point {
476   visibleArea: number;
477   fitsInViewport: boolean;
478 }