|  | /** | 
|  | * @license | 
|  | * Copyright (C) 2016 The Android Open Source Project | 
|  | * | 
|  | * 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. | 
|  | */ | 
|  | (function() { | 
|  | 'use strict'; | 
|  |  | 
|  | const ScrollBehavior = { | 
|  | NEVER: 'never', | 
|  | KEEP_VISIBLE: 'keep-visible', | 
|  | }; | 
|  |  | 
|  | Polymer({ | 
|  | is: 'gr-cursor-manager', | 
|  |  | 
|  | properties: { | 
|  | stops: { | 
|  | type: Array, | 
|  | value() { | 
|  | return []; | 
|  | }, | 
|  | observer: '_updateIndex', | 
|  | }, | 
|  | /** | 
|  | * @type (?Object) | 
|  | */ | 
|  | target: { | 
|  | type: Object, | 
|  | notify: true, | 
|  | observer: '_scrollToTarget', | 
|  | }, | 
|  | /** | 
|  | * The height of content intended to be included with the target. | 
|  | * @type (?number) | 
|  | */ | 
|  | _targetHeight: Number, | 
|  |  | 
|  | /** | 
|  | * The index of the current target (if any). -1 otherwise. | 
|  | */ | 
|  | index: { | 
|  | type: Number, | 
|  | value: -1, | 
|  | notify: true, | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * The class to apply to the current target. Use null for no class. | 
|  | */ | 
|  | cursorTargetClass: { | 
|  | type: String, | 
|  | value: null, | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * The scroll behavior for the cursor. Values are 'never' and | 
|  | * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond | 
|  | * the viewport. | 
|  | * TODO (beckysiegel) figure out why it can be undefined | 
|  | * @type (string|undefined) | 
|  | */ | 
|  | scrollBehavior: { | 
|  | type: String, | 
|  | value: ScrollBehavior.NEVER, | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * When true, will call element.focus() during scrolling. | 
|  | */ | 
|  | focusOnMove: { | 
|  | type: Boolean, | 
|  | value: false, | 
|  | }, | 
|  | }, | 
|  |  | 
|  | detached() { | 
|  | this.unsetCursor(); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Move the cursor forward. Clipped to the ends of the stop list. | 
|  | * @param {!Function=} opt_condition Optional stop condition. If a condition | 
|  | *    is passed the cursor will continue to move in the specified direction | 
|  | *    until the condition is met. | 
|  | * @param {!Function=} opt_getTargetHeight Optional function to calculate the | 
|  | *    height of the target's 'section'. The height of the target itself is | 
|  | *    sometimes different, used by the diff cursor. | 
|  | * @param {boolean=} opt_clipToTop When none of the next indices match, move | 
|  | *     back to first instead of to last. | 
|  | * @private | 
|  | */ | 
|  |  | 
|  | next(opt_condition, opt_getTargetHeight, opt_clipToTop) { | 
|  | this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop); | 
|  | }, | 
|  |  | 
|  | previous(opt_condition) { | 
|  | this._moveCursor(-1, opt_condition); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Set the cursor to an arbitrary element. | 
|  | * @param {!HTMLElement} element | 
|  | * @param {boolean=} opt_noScroll prevent any potential scrolling in response | 
|  | *   setting the cursor. | 
|  | */ | 
|  | setCursor(element, opt_noScroll) { | 
|  | let behavior; | 
|  | if (opt_noScroll) { | 
|  | behavior = this.scrollBehavior; | 
|  | this.scrollBehavior = ScrollBehavior.NEVER; | 
|  | } | 
|  |  | 
|  | this.unsetCursor(); | 
|  | this.target = element; | 
|  | this._updateIndex(); | 
|  | this._decorateTarget(); | 
|  |  | 
|  | if (opt_noScroll) { this.scrollBehavior = behavior; } | 
|  | }, | 
|  |  | 
|  | unsetCursor() { | 
|  | this._unDecorateTarget(); | 
|  | this.index = -1; | 
|  | this.target = null; | 
|  | this._targetHeight = null; | 
|  | }, | 
|  |  | 
|  | isAtStart() { | 
|  | return this.index === 0; | 
|  | }, | 
|  |  | 
|  | isAtEnd() { | 
|  | return this.index === this.stops.length - 1; | 
|  | }, | 
|  |  | 
|  | moveToStart() { | 
|  | if (this.stops.length) { | 
|  | this.setCursor(this.stops[0]); | 
|  | } | 
|  | }, | 
|  |  | 
|  | setCursorAtIndex(index, opt_noScroll) { | 
|  | this.setCursor(this.stops[index], opt_noScroll); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Move the cursor forward or backward by delta. Clipped to the beginning or | 
|  | * end of stop list. | 
|  | * @param {number} delta either -1 or 1. | 
|  | * @param {!Function=} opt_condition Optional stop condition. If a condition | 
|  | *    is passed the cursor will continue to move in the specified direction | 
|  | *    until the condition is met. | 
|  | * @param {!Function=} opt_getTargetHeight Optional function to calculate the | 
|  | *    height of the target's 'section'. The height of the target itself is | 
|  | *    sometimes different, used by the diff cursor. | 
|  | * @param {boolean=} opt_clipToTop When none of the next indices match, move | 
|  | *     back to first instead of to last. | 
|  | * @private | 
|  | */ | 
|  | _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) { | 
|  | if (!this.stops.length) { | 
|  | this.unsetCursor(); | 
|  | return; | 
|  | } | 
|  |  | 
|  | this._unDecorateTarget(); | 
|  |  | 
|  | const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop); | 
|  |  | 
|  | let newTarget = null; | 
|  | if (newIndex !== -1) { | 
|  | newTarget = this.stops[newIndex]; | 
|  | } | 
|  |  | 
|  | this.index = newIndex; | 
|  | this.target = newTarget; | 
|  |  | 
|  | if (!this.target) { return; } | 
|  |  | 
|  | if (opt_getTargetHeight) { | 
|  | this._targetHeight = opt_getTargetHeight(newTarget); | 
|  | } else { | 
|  | this._targetHeight = newTarget.scrollHeight; | 
|  | } | 
|  |  | 
|  | if (this.focusOnMove) { this.target.focus(); } | 
|  |  | 
|  | this._decorateTarget(); | 
|  | }, | 
|  |  | 
|  | _decorateTarget() { | 
|  | if (this.target && this.cursorTargetClass) { | 
|  | this.target.classList.add(this.cursorTargetClass); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _unDecorateTarget() { | 
|  | if (this.target && this.cursorTargetClass) { | 
|  | this.target.classList.remove(this.cursorTargetClass); | 
|  | } | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Get the next stop index indicated by the delta direction. | 
|  | * @param {number} delta either -1 or 1. | 
|  | * @param {!Function=} opt_condition Optional stop condition. | 
|  | * @param {boolean=} opt_clipToTop When none of the next indices match, move | 
|  | *     back to first instead of to last. | 
|  | * @return {number} the new index. | 
|  | * @private | 
|  | */ | 
|  | _getNextindex(delta, opt_condition, opt_clipToTop) { | 
|  | if (!this.stops.length || this.index === -1) { | 
|  | return -1; | 
|  | } | 
|  |  | 
|  | let newIndex = this.index; | 
|  | do { | 
|  | newIndex = newIndex + delta; | 
|  | } while (newIndex > 0 && | 
|  | newIndex < this.stops.length - 1 && | 
|  | opt_condition && !opt_condition(this.stops[newIndex])); | 
|  |  | 
|  | newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex)); | 
|  |  | 
|  | // If we failed to satisfy the condition: | 
|  | if (opt_condition && !opt_condition(this.stops[newIndex])) { | 
|  | if (delta < 0 || opt_clipToTop) { | 
|  | return 0; | 
|  | } else if (delta > 0) { | 
|  | return this.stops.length - 1; | 
|  | } | 
|  | return this.index; | 
|  | } | 
|  |  | 
|  | return newIndex; | 
|  | }, | 
|  |  | 
|  | _updateIndex() { | 
|  | if (!this.target) { | 
|  | this.index = -1; | 
|  | return; | 
|  | } | 
|  |  | 
|  | const newIndex = Array.prototype.indexOf.call(this.stops, this.target); | 
|  | if (newIndex === -1) { | 
|  | this.unsetCursor(); | 
|  | } else { | 
|  | this.index = newIndex; | 
|  | } | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Calculate where the element is relative to the window. | 
|  | * @param {!Object} target Target to scroll to. | 
|  | * @return {number} Distance to top of the target. | 
|  | */ | 
|  | _getTop(target) { | 
|  | let top = target.offsetTop; | 
|  | for (let offsetParent = target.offsetParent; | 
|  | offsetParent; | 
|  | offsetParent = offsetParent.offsetParent) { | 
|  | top += offsetParent.offsetTop; | 
|  | } | 
|  | return top; | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * @return {boolean} | 
|  | */ | 
|  | _targetIsVisible(top) { | 
|  | const dims = this._getWindowDims(); | 
|  | return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE && | 
|  | top > dims.pageYOffset && | 
|  | top < dims.pageYOffset + dims.innerHeight; | 
|  | }, | 
|  |  | 
|  | _calculateScrollToValue(top, target) { | 
|  | const dims = this._getWindowDims(); | 
|  | return top - (dims.innerHeight / 3) + (target.offsetHeight / 2); | 
|  | }, | 
|  |  | 
|  | _scrollToTarget() { | 
|  | if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | const dims = this._getWindowDims(); | 
|  | const top = this._getTop(this.target); | 
|  | const bottomIsVisible = this._targetHeight ? | 
|  | this._targetIsVisible(top + this._targetHeight) : true; | 
|  | const scrollToValue = this._calculateScrollToValue(top, this.target); | 
|  |  | 
|  | if (this._targetIsVisible(top)) { | 
|  | // Don't scroll if either the bottom is visible or if the position that | 
|  | // would get scrolled to is higher up than the current position. this | 
|  | // woulld cause less of the target content to be displayed than is | 
|  | // already. | 
|  | if (bottomIsVisible || scrollToValue < dims.scrollY) { | 
|  | return; | 
|  | } | 
|  | } | 
|  |  | 
|  | // Scroll the element to the middle of the window. Dividing by a third | 
|  | // instead of half the inner height feels a bit better otherwise the | 
|  | // element appears to be below the center of the window even when it | 
|  | // isn't. | 
|  | window.scrollTo(dims.scrollX, scrollToValue); | 
|  | }, | 
|  |  | 
|  | _getWindowDims() { | 
|  | return { | 
|  | scrollX: window.scrollX, | 
|  | scrollY: window.scrollY, | 
|  | innerHeight: window.innerHeight, | 
|  | pageYOffset: window.pageYOffset, | 
|  | }; | 
|  | }, | 
|  | }); | 
|  | })(); |