| /** |
| * @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. |
| */ |
| import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; |
| import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-cursor-manager_html'; |
| import {ScrollMode} from '../../../constants/constants'; |
| import {customElement, property, observe} from '@polymer/decorators'; |
| |
| export interface GrCursorManager { |
| $: {}; |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-cursor-manager': GrCursorManager; |
| } |
| } |
| |
| // Time in which pressing n key again after the toast navigates to next file |
| const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000; |
| |
| @customElement('gr-cursor-manager') |
| export class GrCursorManager extends GestureEventListeners( |
| LegacyElementMixin(PolymerElement) |
| ) { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| @property({type: Object, notify: true}) |
| target: HTMLElement | null = null; |
| |
| /** |
| * The height of content intended to be included with the target. |
| */ |
| @property({type: Number}) |
| _targetHeight: number | null = null; |
| |
| /** |
| * The index of the current target (if any). -1 otherwise. |
| */ |
| @property({type: Number, notify: true}) |
| index = -1; |
| |
| /** |
| * The class to apply to the current target. Use null for no class. |
| */ |
| @property({type: String}) |
| cursorTargetClass: string | null = 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} |
| */ |
| @property({type: String}) |
| scrollMode: string = ScrollMode.NEVER; |
| |
| /** |
| * When true, will call element.focus() during scrolling. |
| */ |
| @property({type: Boolean}) |
| focusOnMove = false; |
| |
| private _lastDisplayedNavigateToNextFileToast: number | null = null; |
| |
| @property({type: Array}) |
| stops: HTMLElement[] = []; |
| |
| /** @override */ |
| detached() { |
| super.detached(); |
| this.unsetCursor(); |
| } |
| |
| /** |
| * Move the cursor forward. Clipped to the ends of the stop list. |
| * |
| * @param 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 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 clipToTop When none of the next indices match, move |
| * back to first instead of to last. |
| * @param navigateToNextFile Navigate to next unreviewed file |
| * if user presses next on the last diff chunk |
| * @private |
| */ |
| |
| next( |
| condition?: Function, |
| getTargetHeight?: (target: HTMLElement) => number, |
| clipToTop?: boolean, |
| navigateToNextFile?: boolean |
| ) { |
| this._moveCursor( |
| 1, |
| condition, |
| getTargetHeight, |
| clipToTop, |
| navigateToNextFile |
| ); |
| } |
| |
| previous(condition?: Function) { |
| this._moveCursor(-1, condition); |
| } |
| |
| /** |
| * Move the cursor to the row which is the closest to the viewport center |
| * in vertical direction. |
| * The method uses IntersectionObservers API. If browser |
| * doesn't support this API the method does nothing |
| * |
| * @param condition Optional condition. If a condition |
| * is passed only stops which meet conditions are taken into account. |
| */ |
| moveToVisibleArea(condition?: (el: Element) => boolean) { |
| if (!this.stops || !this._isIntersectionObserverSupported()) { |
| return; |
| } |
| const filteredStops = condition ? this.stops.filter(condition) : this.stops; |
| const dims = this._getWindowDims(); |
| const windowCenter = Math.round(dims.innerHeight / 2); |
| |
| let closestToTheCenter: HTMLElement | null = null; |
| let minDistanceToCenter: number | null = null; |
| let unobservedCount = filteredStops.length; |
| |
| const observer = new IntersectionObserver(entries => { |
| // This callback is called for the first time immediately. |
| // Typically it gets all observed stops at once, but |
| // sometimes can get them in several chunks. |
| entries.forEach(entry => { |
| observer.unobserve(entry.target); |
| |
| // In Edge it is recommended to use intersectionRatio instead of |
| // isIntersecting. |
| const isInsideViewport = |
| entry.isIntersecting || entry.intersectionRatio > 0; |
| if (!isInsideViewport) { |
| return; |
| } |
| const center = |
| entry.boundingClientRect.top + |
| Math.round(entry.boundingClientRect.height / 2); |
| const distanceToWindowCenter = Math.abs(center - windowCenter); |
| if ( |
| minDistanceToCenter === null || |
| distanceToWindowCenter < minDistanceToCenter |
| ) { |
| // entry.target comes from the filteredStops array, |
| // hence it is an HTMLElement |
| closestToTheCenter = entry.target as HTMLElement; |
| minDistanceToCenter = distanceToWindowCenter; |
| } |
| }); |
| unobservedCount -= entries.length; |
| if (unobservedCount === 0 && closestToTheCenter) { |
| // set cursor when all stops were observed. |
| // In most cases the target is visible, so scroll is not |
| // needed. But in rare cases the target can become invisible |
| // at this point (due to some scrolling in window). |
| // To avoid jumps set noScroll options. |
| this.setCursor(closestToTheCenter, true); |
| } |
| }); |
| filteredStops.forEach(stop => { |
| observer.observe(stop); |
| }); |
| } |
| |
| _isIntersectionObserverSupported() { |
| // The copy of this method exists in gr-app-element.js under the |
| // name _isCursorManagerSupportMoveToVisibleLine |
| // If you update this method, you must update gr-app-element.js |
| // as well. |
| return 'IntersectionObserver' in window; |
| } |
| |
| /** |
| * Set the cursor to an arbitrary element. |
| * |
| * @param noScroll prevent any potential scrolling in response |
| * setting the cursor. |
| */ |
| setCursor(element: HTMLElement, noScroll?: boolean) { |
| let behavior; |
| if (noScroll) { |
| behavior = this.scrollMode; |
| this.scrollMode = ScrollMode.NEVER; |
| } |
| |
| this.unsetCursor(); |
| this.target = element; |
| this._updateIndex(); |
| this._decorateTarget(); |
| |
| if (noScroll && behavior) { |
| this.scrollMode = 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]); |
| } |
| } |
| |
| moveToEnd() { |
| if (this.stops.length) { |
| this.setCursor(this.stops[this.stops.length - 1]); |
| } |
| } |
| |
| setCursorAtIndex(index: number, noScroll?: boolean) { |
| this.setCursor(this.stops[index], noScroll); |
| } |
| |
| /** |
| * Move the cursor forward or backward by delta. Clipped to the beginning or |
| * end of stop list. |
| * |
| * @param delta either -1 or 1. |
| * @param 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 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 clipToTop When none of the next indices match, move |
| * back to first instead of to last. |
| * @param navigateToNextFile Navigate to next unreviewed file |
| * if user presses next on the last diff chunk |
| * @private |
| */ |
| _moveCursor( |
| delta: number, |
| condition?: Function, |
| getTargetHeight?: (target: HTMLElement) => number, |
| clipToTop?: boolean, |
| navigateToNextFile?: boolean |
| ) { |
| if (!this.stops.length) { |
| this.unsetCursor(); |
| return; |
| } |
| |
| this._unDecorateTarget(); |
| |
| const newIndex = this._getNextindex(delta, condition, clipToTop); |
| |
| let newTarget = null; |
| if (newIndex !== -1) { |
| newTarget = this.stops[newIndex]; |
| } |
| |
| /* |
| * If user presses n on the last diff chunk, show a toast informing user |
| * that pressing n again will navigate them to next unreviewed file. |
| * If click happens within the time limit, then navigate to next file |
| */ |
| if (navigateToNextFile && this.index === newIndex) { |
| if (newIndex === this.stops.length - 1) { |
| if ( |
| this._lastDisplayedNavigateToNextFileToast && |
| Date.now() - this._lastDisplayedNavigateToNextFileToast <= |
| NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS |
| ) { |
| // reset for next file |
| this._lastDisplayedNavigateToNextFileToast = null; |
| this.dispatchEvent( |
| new CustomEvent('navigate-to-next-unreviewed-file', { |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| return; |
| } |
| this._lastDisplayedNavigateToNextFileToast = Date.now(); |
| this.dispatchEvent( |
| new CustomEvent('show-alert', { |
| detail: { |
| message: 'Press n again to navigate to next unreviewed file', |
| }, |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| return; |
| } |
| } |
| |
| this.index = newIndex; |
| this.target = newTarget as HTMLElement; |
| |
| if (!newTarget) { |
| return; |
| } |
| |
| if (getTargetHeight) { |
| this._targetHeight = getTargetHeight(newTarget); |
| } else { |
| this._targetHeight = newTarget.scrollHeight; |
| } |
| |
| if (this.focusOnMove) { |
| newTarget.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 delta either -1 or 1. |
| * @param condition Optional stop condition. |
| * @param clipToTop When none of the next indices match, move |
| * back to first instead of to last. |
| * @return the new index. |
| * @private |
| */ |
| _getNextindex(delta: number, condition?: Function, clipToTop?: boolean) { |
| if (!this.stops.length) { |
| return -1; |
| } |
| let newIndex = this.index; |
| // If the cursor is not yet set and we are going backwards, start at the |
| // back. |
| if (this.index === -1 && delta < 0) { |
| newIndex = this.stops.length; |
| } |
| do { |
| newIndex = newIndex + delta; |
| } while ( |
| (delta > 0 || newIndex > 0) && |
| (delta < 0 || newIndex < this.stops.length - 1) && |
| condition && |
| !condition(this.stops[newIndex]) |
| ); |
| |
| newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex)); |
| |
| // If we failed to satisfy the condition: |
| if (condition && !condition(this.stops[newIndex])) { |
| if (delta < 0 || clipToTop) { |
| return 0; |
| } else if (delta > 0) { |
| return this.stops.length - 1; |
| } |
| return this.index; |
| } |
| |
| return newIndex; |
| } |
| |
| @observe('stops') |
| _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 target Target to scroll to. |
| * @return Distance to top of the target. |
| */ |
| _getTop(target: HTMLElement) { |
| let top: number = target.offsetTop; |
| for ( |
| let offsetParent = target.offsetParent; |
| offsetParent; |
| offsetParent = (offsetParent as HTMLElement).offsetParent |
| ) { |
| top += (offsetParent as HTMLElement).offsetTop; |
| } |
| return top; |
| } |
| |
| /** |
| * @return |
| */ |
| _targetIsVisible(top: number) { |
| const dims = this._getWindowDims(); |
| return ( |
| this.scrollMode === ScrollMode.KEEP_VISIBLE && |
| top > dims.pageYOffset && |
| top < dims.pageYOffset + dims.innerHeight |
| ); |
| } |
| |
| _calculateScrollToValue(top: number, target: HTMLElement) { |
| const dims = this._getWindowDims(); |
| return top + -dims.innerHeight / 3 + target.offsetHeight / 2; |
| } |
| |
| @observe('target') |
| _scrollToTarget() { |
| if (!this.target || this.scrollMode === ScrollMode.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 |
| // would 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, |
| }; |
| } |
| } |