Dave Borowitz | 8cdc76b | 2018-03-26 10:04:27 -0400 | [diff] [blame] | 1 | /** |
| 2 | * @license |
Ben Rohlfs | 94fcbbc | 2022-05-27 10:45:03 +0200 | [diff] [blame] | 3 | * Copyright 2016 Google LLC |
| 4 | * SPDX-License-Identifier: Apache-2.0 |
Dave Borowitz | 8cdc76b | 2018-03-26 10:04:27 -0400 | [diff] [blame] | 5 | */ |
Ole Rehmsen | 4feda7d | 2021-03-19 16:39:44 +0000 | [diff] [blame] | 6 | import {BehaviorSubject} from 'rxjs'; |
Ole Rehmsen | cbfe7b0 | 2021-05-27 22:04:23 +0200 | [diff] [blame] | 7 | import {AbortStop, CursorMoveResult, Stop} from '../../../api/core'; |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 8 | import {ScrollMode} from '../../../constants/constants'; |
Wyatt Allen | 8eba594 | 2016-05-19 14:50:43 -0700 | [diff] [blame] | 9 | |
Ole | 38f8066 | 2020-09-24 15:25:49 +0200 | [diff] [blame] | 10 | /** |
David Ostrovsky | f91f966 | 2021-02-22 19:34:37 +0100 | [diff] [blame] | 11 | * Type guard and checker to check if a stop can be targeted. |
| 12 | * Abort stops cannot be targeted. |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 13 | */ |
| 14 | export function isTargetable(stop: Stop): stop is HTMLElement { |
| 15 | return !(stop instanceof AbortStop); |
| 16 | } |
| 17 | |
Ole Rehmsen | 4feda7d | 2021-03-19 16:39:44 +0000 | [diff] [blame] | 18 | export class GrCursorManager { |
| 19 | get target(): HTMLElement | null { |
| 20 | return this.targetSubject.getValue(); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 21 | } |
| 22 | |
Ole Rehmsen | 4feda7d | 2021-03-19 16:39:44 +0000 | [diff] [blame] | 23 | set target(target: HTMLElement | null) { |
| 24 | this.targetSubject.next(target); |
| 25 | this._scrollToTarget(); |
| 26 | } |
| 27 | |
| 28 | private targetSubject = new BehaviorSubject<HTMLElement | null>(null); |
| 29 | |
| 30 | target$ = this.targetSubject.asObservable(); |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 31 | |
| 32 | /** |
| 33 | * The height of content intended to be included with the target. |
| 34 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 35 | _targetHeight: number | null = null; |
| 36 | |
| 37 | /** |
| 38 | * The index of the current target (if any). -1 otherwise. |
| 39 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 40 | index = -1; |
| 41 | |
| 42 | /** |
| 43 | * The class to apply to the current target. Use null for no class. |
| 44 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 45 | cursorTargetClass: string | null = null; |
| 46 | |
| 47 | /** |
| 48 | * The scroll behavior for the cursor. Values are 'never' and |
| 49 | * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond |
| 50 | * the viewport. |
| 51 | * TODO (beckysiegel) figure out why it can be undefined |
| 52 | * |
| 53 | * @type {string|undefined} |
| 54 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 55 | scrollMode: string = ScrollMode.NEVER; |
| 56 | |
| 57 | /** |
| 58 | * When true, will call element.focus() during scrolling. |
| 59 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 60 | focusOnMove = false; |
| 61 | |
Ole Rehmsen | 4feda7d | 2021-03-19 16:39:44 +0000 | [diff] [blame] | 62 | set stops(stops: Stop[]) { |
| 63 | this.stopsInternal = stops; |
| 64 | this._updateIndex(); |
| 65 | } |
| 66 | |
| 67 | get stops(): Stop[] { |
| 68 | return this.stopsInternal; |
| 69 | } |
| 70 | |
| 71 | private stopsInternal: Stop[] = []; |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 72 | |
| 73 | /** Only non-AbortStop stops. */ |
| 74 | get targetableStops(): HTMLElement[] { |
| 75 | return this.stops.filter(isTargetable); |
| 76 | } |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 77 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 78 | /** |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 79 | * Move the cursor forward. Clipped to the end of the stop list. |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 80 | * |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 81 | * @param options.filter Skips any stops for which filter returns false. |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 82 | * @param options.getTargetHeight Optional function to calculate the |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 83 | * height of the target's 'section'. The height of the target itself is |
| 84 | * sometimes different, used by the diff cursor. |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 85 | * @param options.clipToTop When none of the next indices match, move |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 86 | * back to first instead of to last. |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 87 | * @param options.circular When on last element, you get to first element. |
Ole | 38f8066 | 2020-09-24 15:25:49 +0200 | [diff] [blame] | 88 | * @return If a move was performed or why not. |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 89 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 90 | next( |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 91 | options: { |
| 92 | filter?: (stop: HTMLElement) => boolean; |
| 93 | getTargetHeight?: (target: HTMLElement) => number; |
| 94 | clipToTop?: boolean; |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 95 | circular?: boolean; |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 96 | } = {} |
Ole | 38f8066 | 2020-09-24 15:25:49 +0200 | [diff] [blame] | 97 | ): CursorMoveResult { |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 98 | return this._moveCursor(1, options); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 99 | } |
| 100 | |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 101 | /** |
| 102 | * Move the cursor backward. Clipped to the beginning of stop list. |
| 103 | * |
| 104 | * @param options.filter Skips any stops for which filter returns false. |
| 105 | * @param options.getTargetHeight Optional function to calculate the |
| 106 | * height of the target's 'section'. The height of the target itself is |
| 107 | * sometimes different, used by the diff cursor. |
| 108 | * @param options.clipToTop When none of the next indices match, move |
| 109 | * back to first instead of to last. |
| 110 | * @param options.circular When on first element, you get to last element. |
| 111 | * @return If a move was performed or why not. |
| 112 | */ |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 113 | previous( |
| 114 | options: { |
| 115 | filter?: (stop: HTMLElement) => boolean; |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 116 | circular?: boolean; |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 117 | } = {} |
| 118 | ): CursorMoveResult { |
| 119 | return this._moveCursor(-1, options); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Move the cursor to the row which is the closest to the viewport center |
| 124 | * in vertical direction. |
| 125 | * The method uses IntersectionObservers API. If browser |
| 126 | * doesn't support this API the method does nothing |
| 127 | * |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 128 | * @param filter Skips any stops for which filter returns false. |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 129 | */ |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 130 | async moveToVisibleArea(filter?: (el: Element) => boolean) { |
| 131 | const centerMostStop = await this.getCenterMostStop(filter); |
| 132 | // In most cases the target is visible, so scroll is not |
| 133 | // needed. But in rare cases the target can become invisible |
| 134 | // at this point (due to some scrolling in window). |
| 135 | // To avoid jumps set noScroll options. |
| 136 | if (centerMostStop) { |
| 137 | this.setCursor(centerMostStop, true); |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | private async getCenterMostStop( |
| 142 | filter?: (el: Element) => boolean |
| 143 | ): Promise<HTMLElement | undefined> { |
| 144 | const visibleEntries = await this.getVisibleEntries(filter); |
| 145 | const windowCenter = Math.round(window.innerHeight / 2); |
| 146 | |
| 147 | let centerMostStop: HTMLElement | undefined = undefined; |
| 148 | let minDistanceToCenter = Number.MAX_VALUE; |
| 149 | |
| 150 | for (const entry of visibleEntries) { |
| 151 | // We are just using the entries here, because entry.boundingClientRect |
| 152 | // is already computed, but entry.target.getBoundingClientRect() should |
| 153 | // actually yield the same result. |
| 154 | const center = |
| 155 | entry.boundingClientRect.top + |
| 156 | Math.round(entry.boundingClientRect.height / 2); |
| 157 | const distanceToWindowCenter = Math.abs(center - windowCenter); |
| 158 | if (distanceToWindowCenter < minDistanceToCenter) { |
| 159 | // entry.target comes from the filteredStops array, |
| 160 | // hence it is an HTMLElement |
| 161 | centerMostStop = entry.target as HTMLElement; |
| 162 | minDistanceToCenter = distanceToWindowCenter; |
| 163 | } |
| 164 | } |
| 165 | return centerMostStop; |
| 166 | } |
| 167 | |
| 168 | private async getVisibleEntries( |
| 169 | filter?: (el: Element) => boolean |
| 170 | ): Promise<IntersectionObserverEntry[]> { |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 171 | if (!this.stops) { |
| 172 | return []; |
Dmitrii Filippov | 3fd2b10 | 2019-11-15 16:16:46 +0100 | [diff] [blame] | 173 | } |
Ole Rehmsen | b832538 | 2021-04-09 12:59:52 +0200 | [diff] [blame] | 174 | const filteredStops = filter |
| 175 | ? this.targetableStops.filter(filter) |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 176 | : this.targetableStops; |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 177 | return new Promise(resolve => { |
| 178 | let unobservedCount = filteredStops.length; |
| 179 | const visibleEntries: IntersectionObserverEntry[] = []; |
| 180 | const observer = new IntersectionObserver(entries => { |
| 181 | visibleEntries.push( |
| 182 | ...entries |
| 183 | // In Edge it is recommended to use intersectionRatio instead of |
| 184 | // isIntersecting. |
| 185 | .filter( |
| 186 | entry => entry.isIntersecting || entry.intersectionRatio > 0 |
| 187 | ) |
| 188 | ); |
Wyatt Allen | 972c3de | 2016-05-16 11:40:44 -0700 | [diff] [blame] | 189 | |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 190 | // This callback is called for the first time immediately. |
| 191 | // Typically it gets all observed stops at once, but |
| 192 | // sometimes can get them in several chunks. |
| 193 | for (const entry of entries) { |
| 194 | observer.unobserve(entry.target); |
Becky Siegel | ef950fa | 2017-03-06 20:29:33 -0800 | [diff] [blame] | 195 | } |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 196 | unobservedCount -= entries.length; |
| 197 | if (unobservedCount === 0) { |
| 198 | resolve(visibleEntries); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 199 | } |
| 200 | }); |
Ole Rehmsen | db8ba6f | 2021-04-09 13:50:42 +0200 | [diff] [blame] | 201 | for (const stop of filteredStops) { |
| 202 | observer.observe(stop); |
Wyatt Allen | cca38d2 | 2017-01-17 13:15:16 -0800 | [diff] [blame] | 203 | } |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 204 | }); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 205 | } |
Wyatt Allen | 8eba594 | 2016-05-19 14:50:43 -0700 | [diff] [blame] | 206 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 207 | /** |
Ole | a9f1b68 | 2020-11-06 15:42:47 +0100 | [diff] [blame] | 208 | * Set the cursor to an arbitrary stop - if the given element is not one of |
| 209 | * the stops, unset the cursor. |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 210 | * |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 211 | * @param noScroll prevent any potential scrolling in response |
| 212 | * setting the cursor. |
Renan Oliveira | 97ee475 | 2021-11-11 20:22:41 +0100 | [diff] [blame] | 213 | * @param applyFocus indicates if it should try to focus after move operation |
| 214 | * (e.g. focusOnMove). |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 215 | */ |
Renan Oliveira | 97ee475 | 2021-11-11 20:22:41 +0100 | [diff] [blame] | 216 | setCursor(element: HTMLElement, noScroll?: boolean, applyFocus?: boolean) { |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 217 | if (!this.targetableStops.includes(element)) { |
Ole | a9f1b68 | 2020-11-06 15:42:47 +0100 | [diff] [blame] | 218 | this.unsetCursor(); |
| 219 | return; |
| 220 | } |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 221 | let behavior; |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 222 | if (noScroll) { |
Dhruv Srivastava | 9c853fc | 2020-05-05 13:48:25 +0200 | [diff] [blame] | 223 | behavior = this.scrollMode; |
Dmitrii Filippov | e903bbf | 2020-05-06 12:57:39 +0200 | [diff] [blame] | 224 | this.scrollMode = ScrollMode.NEVER; |
Dmitrii Filippov | 3fd2b10 | 2019-11-15 16:16:46 +0100 | [diff] [blame] | 225 | } |
Wyatt Allen | 7f133f5 | 2018-03-16 16:56:08 -0700 | [diff] [blame] | 226 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 227 | this.unsetCursor(); |
| 228 | this.target = element; |
| 229 | this._updateIndex(); |
| 230 | this._decorateTarget(); |
| 231 | |
Renan Oliveira | 97ee475 | 2021-11-11 20:22:41 +0100 | [diff] [blame] | 232 | if (applyFocus) { |
| 233 | this._focusAfterMove(); |
| 234 | } |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 235 | if (noScroll && behavior) { |
| 236 | this.scrollMode = behavior; |
| 237 | } |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 238 | } |
| 239 | |
| 240 | unsetCursor() { |
| 241 | this._unDecorateTarget(); |
| 242 | this.index = -1; |
| 243 | this.target = null; |
| 244 | this._targetHeight = null; |
| 245 | } |
| 246 | |
Ole | 2ac4609 | 2020-11-20 14:35:04 +0100 | [diff] [blame] | 247 | /** Returns true if there are no stops, or we are on the first stop. */ |
| 248 | isAtStart(): boolean { |
| 249 | return this.stops.length === 0 || this.index === 0; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 250 | } |
| 251 | |
Ole | 2ac4609 | 2020-11-20 14:35:04 +0100 | [diff] [blame] | 252 | /** Returns true if there are no stops, or we are on the last stop. */ |
| 253 | isAtEnd(): boolean { |
| 254 | return this.stops.length === 0 || this.index === this.stops.length - 1; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 255 | } |
| 256 | |
| 257 | moveToStart() { |
| 258 | if (this.stops.length) { |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 259 | this.setCursorAtIndex(0); |
Dmitrii Filippov | 3fd2b10 | 2019-11-15 16:16:46 +0100 | [diff] [blame] | 260 | } |
| 261 | } |
| 262 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 263 | moveToEnd() { |
| 264 | if (this.stops.length) { |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 265 | this.setCursorAtIndex(this.stops.length - 1); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 266 | } |
| 267 | } |
| 268 | |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 269 | setCursorAtIndex(index: number, noScroll?: boolean) { |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 270 | const stop = this.stops[index]; |
| 271 | if (isTargetable(stop)) { |
| 272 | this.setCursor(stop, noScroll); |
| 273 | } |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 274 | } |
| 275 | |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 276 | _moveCursor( |
| 277 | delta: number, |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 278 | { |
| 279 | filter, |
| 280 | getTargetHeight, |
| 281 | clipToTop, |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 282 | circular, |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 283 | }: { |
| 284 | filter?: (stop: HTMLElement) => boolean; |
| 285 | getTargetHeight?: (target: HTMLElement) => number; |
| 286 | clipToTop?: boolean; |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 287 | circular?: boolean; |
Ole | b29fa98 | 2020-09-24 16:22:01 +0200 | [diff] [blame] | 288 | } = {} |
Ole | 38f8066 | 2020-09-24 15:25:49 +0200 | [diff] [blame] | 289 | ): CursorMoveResult { |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 290 | if (!this.stops.length) { |
| 291 | this.unsetCursor(); |
Ole | 38f8066 | 2020-09-24 15:25:49 +0200 | [diff] [blame] | 292 | return CursorMoveResult.NO_STOPS; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 293 | } |
| 294 | |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 295 | let newIndex = this.index; |
| 296 | // If the cursor is not yet set and we are going backwards, start at the |
| 297 | // back. |
| 298 | if (this.index === -1 && delta < 0) { |
| 299 | newIndex = this.stops.length; |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 300 | } |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 301 | |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 302 | let clipped = false; |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 303 | let newStop: Stop; |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 304 | do { |
| 305 | newIndex += delta; |
| 306 | if ( |
| 307 | (delta > 0 && newIndex >= this.stops.length) || |
| 308 | (delta < 0 && newIndex < 0) |
| 309 | ) { |
Milutin Kristofic | b813063 | 2021-05-27 20:34:03 +0200 | [diff] [blame] | 310 | newIndex = |
| 311 | (delta < 0 && !circular) || (delta > 0 && circular) || clipToTop |
| 312 | ? 0 |
| 313 | : this.stops.length - 1; |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 314 | newStop = this.stops[newIndex]; |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 315 | clipped = true; |
| 316 | break; |
| 317 | } |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 318 | // Sadly needed so that type narrowing understands that this.stops[newIndex] is |
| 319 | // targetable after I have checked that. |
| 320 | newStop = this.stops[newIndex]; |
| 321 | } while (isTargetable(newStop) && filter && !filter(newStop)); |
| 322 | |
| 323 | if (!isTargetable(newStop)) { |
| 324 | return CursorMoveResult.ABORTED; |
| 325 | } |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 326 | |
| 327 | this._unDecorateTarget(); |
| 328 | |
| 329 | this.index = newIndex; |
Ole | b15e4d9 | 2020-11-06 16:29:30 +0100 | [diff] [blame] | 330 | this.target = newStop; |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 331 | |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 332 | if (getTargetHeight) { |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 333 | this._targetHeight = getTargetHeight(this.target); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 334 | } else { |
Ole | 2d19540 | 2020-09-24 16:56:53 +0200 | [diff] [blame] | 335 | this._targetHeight = this.target.scrollHeight; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 336 | } |
| 337 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 338 | this._decorateTarget(); |
Renan Oliveira | 97ee475 | 2021-11-11 20:22:41 +0100 | [diff] [blame] | 339 | this._focusAfterMove(); |
Ole | 38f8066 | 2020-09-24 15:25:49 +0200 | [diff] [blame] | 340 | return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 341 | } |
| 342 | |
Renan Oliveira | 97ee475 | 2021-11-11 20:22:41 +0100 | [diff] [blame] | 343 | _focusAfterMove() { |
| 344 | if (this.focusOnMove) { |
| 345 | this.target?.focus(); |
| 346 | } |
| 347 | } |
| 348 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 349 | _decorateTarget() { |
| 350 | if (this.target && this.cursorTargetClass) { |
| 351 | this.target.classList.add(this.cursorTargetClass); |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | _unDecorateTarget() { |
| 356 | if (this.target && this.cursorTargetClass) { |
| 357 | this.target.classList.remove(this.cursorTargetClass); |
| 358 | } |
| 359 | } |
| 360 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 361 | _updateIndex() { |
| 362 | if (!this.target) { |
| 363 | this.index = -1; |
| 364 | return; |
| 365 | } |
| 366 | |
Ole | a9f1b68 | 2020-11-06 15:42:47 +0100 | [diff] [blame] | 367 | const newIndex = this.stops.indexOf(this.target); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 368 | if (newIndex === -1) { |
| 369 | this.unsetCursor(); |
| 370 | } else { |
| 371 | this.index = newIndex; |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | /** |
| 376 | * Calculate where the element is relative to the window. |
| 377 | * |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 378 | * @param target Target to scroll to. |
| 379 | * @return Distance to top of the target. |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 380 | */ |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 381 | _getTop(target: HTMLElement) { |
| 382 | let top: number = target.offsetTop; |
| 383 | for ( |
| 384 | let offsetParent = target.offsetParent; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 385 | offsetParent; |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 386 | offsetParent = (offsetParent as HTMLElement).offsetParent |
| 387 | ) { |
| 388 | top += (offsetParent as HTMLElement).offsetTop; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 389 | } |
| 390 | return top; |
| 391 | } |
| 392 | |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 393 | _targetIsVisible(top: number) { |
Frank Borden | acca996 | 2024-01-04 15:50:13 +0100 | [diff] [blame] | 394 | // Targets near the top are often covered by sticky header UI, so we |
| 395 | // consider it not-visible if it is within 100px of the top. |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 396 | return ( |
| 397 | this.scrollMode === ScrollMode.KEEP_VISIBLE && |
Frank Borden | acca996 | 2024-01-04 15:50:13 +0100 | [diff] [blame] | 398 | top > window.scrollY + 100 && |
| 399 | top < window.scrollY + window.innerHeight |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 400 | ); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 401 | } |
| 402 | |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 403 | _calculateScrollToValue(top: number, target: HTMLElement) { |
Ole Rehmsen | 5a209cc | 2021-04-09 13:33:58 +0200 | [diff] [blame] | 404 | return top + -window.innerHeight / 3 + target.offsetHeight / 2; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 405 | } |
| 406 | |
| 407 | _scrollToTarget() { |
Dmitrii Filippov | e903bbf | 2020-05-06 12:57:39 +0200 | [diff] [blame] | 408 | if (!this.target || this.scrollMode === ScrollMode.NEVER) { |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 409 | return; |
| 410 | } |
| 411 | |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 412 | const top = this._getTop(this.target); |
Dhruv Srivastava | 6245e7a | 2020-08-03 12:31:05 +0200 | [diff] [blame] | 413 | const bottomIsVisible = this._targetHeight |
| 414 | ? this._targetIsVisible(top + this._targetHeight) |
| 415 | : true; |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 416 | const scrollToValue = this._calculateScrollToValue(top, this.target); |
| 417 | |
| 418 | if (this._targetIsVisible(top)) { |
| 419 | // Don't scroll if either the bottom is visible or if the position that |
David Ostrovsky | a2401c1 | 2020-05-03 19:29:26 +0200 | [diff] [blame] | 420 | // would get scrolled to is higher up than the current position. This |
| 421 | // would cause less of the target content to be displayed than is |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 422 | // already. |
Ole Rehmsen | 5a209cc | 2021-04-09 13:33:58 +0200 | [diff] [blame] | 423 | if (bottomIsVisible || scrollToValue < window.scrollY) { |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 424 | return; |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | // Scroll the element to the middle of the window. Dividing by a third |
| 429 | // instead of half the inner height feels a bit better otherwise the |
| 430 | // element appears to be below the center of the window even when it |
| 431 | // isn't. |
Ole Rehmsen | 5a209cc | 2021-04-09 13:33:58 +0200 | [diff] [blame] | 432 | window.scrollTo(window.scrollX, scrollToValue); |
Dmitrii Filippov | daf0ec9 | 2020-03-17 11:27:28 +0100 | [diff] [blame] | 433 | } |
| 434 | } |