blob: 35567be6e95e9af573146026ede0c8dc9f4c5663 [file] [log] [blame]
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04001/**
2 * @license
Ben Rohlfs94fcbbc2022-05-27 10:45:03 +02003 * Copyright 2016 Google LLC
4 * SPDX-License-Identifier: Apache-2.0
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04005 */
Ole Rehmsen4feda7d2021-03-19 16:39:44 +00006import {BehaviorSubject} from 'rxjs';
Ole Rehmsencbfe7b02021-05-27 22:04:23 +02007import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +02008import {ScrollMode} from '../../../constants/constants';
Wyatt Allen8eba5942016-05-19 14:50:43 -07009
Ole38f80662020-09-24 15:25:49 +020010/**
David Ostrovskyf91f9662021-02-22 19:34:37 +010011 * Type guard and checker to check if a stop can be targeted.
12 * Abort stops cannot be targeted.
Oleb15e4d92020-11-06 16:29:30 +010013 */
14export function isTargetable(stop: Stop): stop is HTMLElement {
15 return !(stop instanceof AbortStop);
16}
17
Ole Rehmsen4feda7d2021-03-19 16:39:44 +000018export class GrCursorManager {
19 get target(): HTMLElement | null {
20 return this.targetSubject.getValue();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010021 }
22
Ole Rehmsen4feda7d2021-03-19 16:39:44 +000023 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 Srivastava6245e7a2020-08-03 12:31:05 +020031
32 /**
33 * The height of content intended to be included with the target.
34 */
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +020035 _targetHeight: number | null = null;
36
37 /**
38 * The index of the current target (if any). -1 otherwise.
39 */
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +020040 index = -1;
41
42 /**
43 * The class to apply to the current target. Use null for no class.
44 */
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +020045 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 Srivastava6245e7a2020-08-03 12:31:05 +020055 scrollMode: string = ScrollMode.NEVER;
56
57 /**
58 * When true, will call element.focus() during scrolling.
59 */
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +020060 focusOnMove = false;
61
Ole Rehmsen4feda7d2021-03-19 16:39:44 +000062 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[] = [];
Oleb15e4d92020-11-06 16:29:30 +010072
73 /** Only non-AbortStop stops. */
74 get targetableStops(): HTMLElement[] {
75 return this.stops.filter(isTargetable);
76 }
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +020077
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010078 /**
Milutin Kristoficb8130632021-05-27 20:34:03 +020079 * Move the cursor forward. Clipped to the end of the stop list.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010080 *
Ole Rehmsendb8ba6f2021-04-09 13:50:42 +020081 * @param options.filter Skips any stops for which filter returns false.
Oleb29fa982020-09-24 16:22:01 +020082 * @param options.getTargetHeight Optional function to calculate the
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010083 * height of the target's 'section'. The height of the target itself is
84 * sometimes different, used by the diff cursor.
Oleb29fa982020-09-24 16:22:01 +020085 * @param options.clipToTop When none of the next indices match, move
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010086 * back to first instead of to last.
Milutin Kristoficb8130632021-05-27 20:34:03 +020087 * @param options.circular When on last element, you get to first element.
Ole38f80662020-09-24 15:25:49 +020088 * @return If a move was performed or why not.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010089 */
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +020090 next(
Oleb29fa982020-09-24 16:22:01 +020091 options: {
92 filter?: (stop: HTMLElement) => boolean;
93 getTargetHeight?: (target: HTMLElement) => number;
94 clipToTop?: boolean;
Milutin Kristoficb8130632021-05-27 20:34:03 +020095 circular?: boolean;
Oleb29fa982020-09-24 16:22:01 +020096 } = {}
Ole38f80662020-09-24 15:25:49 +020097 ): CursorMoveResult {
Oleb29fa982020-09-24 16:22:01 +020098 return this._moveCursor(1, options);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010099 }
100
Milutin Kristoficb8130632021-05-27 20:34:03 +0200101 /**
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 */
Oleb29fa982020-09-24 16:22:01 +0200113 previous(
114 options: {
115 filter?: (stop: HTMLElement) => boolean;
Milutin Kristoficb8130632021-05-27 20:34:03 +0200116 circular?: boolean;
Oleb29fa982020-09-24 16:22:01 +0200117 } = {}
118 ): CursorMoveResult {
119 return this._moveCursor(-1, options);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100120 }
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 Rehmsendb8ba6f2021-04-09 13:50:42 +0200128 * @param filter Skips any stops for which filter returns false.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100129 */
Ole Rehmsendb8ba6f2021-04-09 13:50:42 +0200130 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 Rehmsendb8ba6f2021-04-09 13:50:42 +0200171 if (!this.stops) {
172 return [];
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +0100173 }
Ole Rehmsenb8325382021-04-09 12:59:52 +0200174 const filteredStops = filter
175 ? this.targetableStops.filter(filter)
Oleb15e4d92020-11-06 16:29:30 +0100176 : this.targetableStops;
Ole Rehmsendb8ba6f2021-04-09 13:50:42 +0200177 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 Allen972c3de2016-05-16 11:40:44 -0700189
Ole Rehmsendb8ba6f2021-04-09 13:50:42 +0200190 // 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 Siegelef950fa2017-03-06 20:29:33 -0800195 }
Ole Rehmsendb8ba6f2021-04-09 13:50:42 +0200196 unobservedCount -= entries.length;
197 if (unobservedCount === 0) {
198 resolve(visibleEntries);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100199 }
200 });
Ole Rehmsendb8ba6f2021-04-09 13:50:42 +0200201 for (const stop of filteredStops) {
202 observer.observe(stop);
Wyatt Allencca38d22017-01-17 13:15:16 -0800203 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100204 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100205 }
Wyatt Allen8eba5942016-05-19 14:50:43 -0700206
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100207 /**
Olea9f1b682020-11-06 15:42:47 +0100208 * Set the cursor to an arbitrary stop - if the given element is not one of
209 * the stops, unset the cursor.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100210 *
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200211 * @param noScroll prevent any potential scrolling in response
212 * setting the cursor.
Renan Oliveira97ee4752021-11-11 20:22:41 +0100213 * @param applyFocus indicates if it should try to focus after move operation
214 * (e.g. focusOnMove).
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100215 */
Renan Oliveira97ee4752021-11-11 20:22:41 +0100216 setCursor(element: HTMLElement, noScroll?: boolean, applyFocus?: boolean) {
Oleb15e4d92020-11-06 16:29:30 +0100217 if (!this.targetableStops.includes(element)) {
Olea9f1b682020-11-06 15:42:47 +0100218 this.unsetCursor();
219 return;
220 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100221 let behavior;
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200222 if (noScroll) {
Dhruv Srivastava9c853fc2020-05-05 13:48:25 +0200223 behavior = this.scrollMode;
Dmitrii Filippove903bbf2020-05-06 12:57:39 +0200224 this.scrollMode = ScrollMode.NEVER;
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +0100225 }
Wyatt Allen7f133f52018-03-16 16:56:08 -0700226
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100227 this.unsetCursor();
228 this.target = element;
229 this._updateIndex();
230 this._decorateTarget();
231
Renan Oliveira97ee4752021-11-11 20:22:41 +0100232 if (applyFocus) {
233 this._focusAfterMove();
234 }
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200235 if (noScroll && behavior) {
236 this.scrollMode = behavior;
237 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100238 }
239
240 unsetCursor() {
241 this._unDecorateTarget();
242 this.index = -1;
243 this.target = null;
244 this._targetHeight = null;
245 }
246
Ole2ac46092020-11-20 14:35:04 +0100247 /** 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 Filippovdaf0ec92020-03-17 11:27:28 +0100250 }
251
Ole2ac46092020-11-20 14:35:04 +0100252 /** 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 Filippovdaf0ec92020-03-17 11:27:28 +0100255 }
256
257 moveToStart() {
258 if (this.stops.length) {
Oleb15e4d92020-11-06 16:29:30 +0100259 this.setCursorAtIndex(0);
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +0100260 }
261 }
262
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100263 moveToEnd() {
264 if (this.stops.length) {
Oleb15e4d92020-11-06 16:29:30 +0100265 this.setCursorAtIndex(this.stops.length - 1);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100266 }
267 }
268
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200269 setCursorAtIndex(index: number, noScroll?: boolean) {
Oleb15e4d92020-11-06 16:29:30 +0100270 const stop = this.stops[index];
271 if (isTargetable(stop)) {
272 this.setCursor(stop, noScroll);
273 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100274 }
275
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200276 _moveCursor(
277 delta: number,
Oleb29fa982020-09-24 16:22:01 +0200278 {
279 filter,
280 getTargetHeight,
281 clipToTop,
Milutin Kristoficb8130632021-05-27 20:34:03 +0200282 circular,
Oleb29fa982020-09-24 16:22:01 +0200283 }: {
284 filter?: (stop: HTMLElement) => boolean;
285 getTargetHeight?: (target: HTMLElement) => number;
286 clipToTop?: boolean;
Milutin Kristoficb8130632021-05-27 20:34:03 +0200287 circular?: boolean;
Oleb29fa982020-09-24 16:22:01 +0200288 } = {}
Ole38f80662020-09-24 15:25:49 +0200289 ): CursorMoveResult {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100290 if (!this.stops.length) {
291 this.unsetCursor();
Ole38f80662020-09-24 15:25:49 +0200292 return CursorMoveResult.NO_STOPS;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100293 }
294
Ole2d195402020-09-24 16:56:53 +0200295 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 Srivastava6245e7a2020-08-03 12:31:05 +0200300 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100301
Ole2d195402020-09-24 16:56:53 +0200302 let clipped = false;
Oleb15e4d92020-11-06 16:29:30 +0100303 let newStop: Stop;
Ole2d195402020-09-24 16:56:53 +0200304 do {
305 newIndex += delta;
306 if (
307 (delta > 0 && newIndex >= this.stops.length) ||
308 (delta < 0 && newIndex < 0)
309 ) {
Milutin Kristoficb8130632021-05-27 20:34:03 +0200310 newIndex =
311 (delta < 0 && !circular) || (delta > 0 && circular) || clipToTop
312 ? 0
313 : this.stops.length - 1;
Oleb15e4d92020-11-06 16:29:30 +0100314 newStop = this.stops[newIndex];
Ole2d195402020-09-24 16:56:53 +0200315 clipped = true;
316 break;
317 }
Oleb15e4d92020-11-06 16:29:30 +0100318 // 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 }
Ole2d195402020-09-24 16:56:53 +0200326
327 this._unDecorateTarget();
328
329 this.index = newIndex;
Oleb15e4d92020-11-06 16:29:30 +0100330 this.target = newStop;
Ole2d195402020-09-24 16:56:53 +0200331
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200332 if (getTargetHeight) {
Ole2d195402020-09-24 16:56:53 +0200333 this._targetHeight = getTargetHeight(this.target);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100334 } else {
Ole2d195402020-09-24 16:56:53 +0200335 this._targetHeight = this.target.scrollHeight;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100336 }
337
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100338 this._decorateTarget();
Renan Oliveira97ee4752021-11-11 20:22:41 +0100339 this._focusAfterMove();
Ole38f80662020-09-24 15:25:49 +0200340 return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100341 }
342
Renan Oliveira97ee4752021-11-11 20:22:41 +0100343 _focusAfterMove() {
344 if (this.focusOnMove) {
345 this.target?.focus();
346 }
347 }
348
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100349 _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 Filippovdaf0ec92020-03-17 11:27:28 +0100361 _updateIndex() {
362 if (!this.target) {
363 this.index = -1;
364 return;
365 }
366
Olea9f1b682020-11-06 15:42:47 +0100367 const newIndex = this.stops.indexOf(this.target);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100368 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 Srivastava6245e7a2020-08-03 12:31:05 +0200378 * @param target Target to scroll to.
379 * @return Distance to top of the target.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100380 */
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200381 _getTop(target: HTMLElement) {
382 let top: number = target.offsetTop;
383 for (
384 let offsetParent = target.offsetParent;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100385 offsetParent;
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200386 offsetParent = (offsetParent as HTMLElement).offsetParent
387 ) {
388 top += (offsetParent as HTMLElement).offsetTop;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100389 }
390 return top;
391 }
392
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200393 _targetIsVisible(top: number) {
Frank Bordenacca9962024-01-04 15:50:13 +0100394 // 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 Srivastava6245e7a2020-08-03 12:31:05 +0200396 return (
397 this.scrollMode === ScrollMode.KEEP_VISIBLE &&
Frank Bordenacca9962024-01-04 15:50:13 +0100398 top > window.scrollY + 100 &&
399 top < window.scrollY + window.innerHeight
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200400 );
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100401 }
402
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200403 _calculateScrollToValue(top: number, target: HTMLElement) {
Ole Rehmsen5a209cc2021-04-09 13:33:58 +0200404 return top + -window.innerHeight / 3 + target.offsetHeight / 2;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100405 }
406
407 _scrollToTarget() {
Dmitrii Filippove903bbf2020-05-06 12:57:39 +0200408 if (!this.target || this.scrollMode === ScrollMode.NEVER) {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100409 return;
410 }
411
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100412 const top = this._getTop(this.target);
Dhruv Srivastava6245e7a2020-08-03 12:31:05 +0200413 const bottomIsVisible = this._targetHeight
414 ? this._targetIsVisible(top + this._targetHeight)
415 : true;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100416 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 Ostrovskya2401c12020-05-03 19:29:26 +0200420 // 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 Filippovdaf0ec92020-03-17 11:27:28 +0100422 // already.
Ole Rehmsen5a209cc2021-04-09 13:33:58 +0200423 if (bottomIsVisible || scrollToValue < window.scrollY) {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100424 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 Rehmsen5a209cc2021-04-09 13:33:58 +0200432 window.scrollTo(window.scrollX, scrollToValue);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100433 }
434}