Skip to content
This repository was archived by the owner on Nov 25, 2021. It is now read-only.

Commit f44f3ab

Browse files
author
Loïc Guychard
authored
feat: add HoverifyOptions.scrollBoundaries (#196)
Rel [RFC 76](https://docs.google.com/document/d/1albi6HaAU9JAqdQXjsE-50NidxG4icE-36XqxxKgfM4/edit) Introduces `HoverifyOptions.scrollBoundaries`, an array of `HTMLElement`, which would typically contain elements with a lower z-index than the hover overlay but a higher z-index than the code view, such as a sticky file header. At runtime, in reaction to scroll events, hide the hover overlay when it overlaps with a hover boundary. `IntersectionObserver` could not be used here, as it requires an ancestor relationship between the root and target elements.
1 parent 5427eab commit f44f3ab

File tree

5 files changed

+214
-45
lines changed

5 files changed

+214
-45
lines changed

src/helpers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,17 @@ export const scrollIntoCenterIfNeeded = (container: HTMLElement, content: HTMLEl
3131
container.scrollTop = scrollTop
3232
}
3333
}
34+
35+
/**
36+
* Returns a curried function that returns `true` if `e1` and `e2` overlap.
37+
*/
38+
export const elementOverlaps = (e1: HTMLElement) => (e2: HTMLElement): boolean => {
39+
const e1Rect = e1.getBoundingClientRect()
40+
const e2Rect = e2.getBoundingClientRect()
41+
return !(
42+
e1Rect.right < e2Rect.left ||
43+
e1Rect.left > e2Rect.right ||
44+
e1Rect.bottom < e2Rect.top ||
45+
e1Rect.top > e2Rect.bottom
46+
)
47+
}

src/hoverifier.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Range } from '@sourcegraph/extension-api-types'
22
import { isEqual } from 'lodash'
33
import { EMPTY, NEVER, Observable, of, Subject, Subscription } from 'rxjs'
4-
import { delay, distinctUntilChanged, filter, first, map } from 'rxjs/operators'
4+
import { delay, distinctUntilChanged, filter, first, map, takeWhile } from 'rxjs/operators'
55
import { TestScheduler } from 'rxjs/testing'
66
import { ErrorLike } from './errors'
7-
import { propertyIsDefined } from './helpers'
7+
import { isDefined, propertyIsDefined } from './helpers'
88
import {
99
AdjustmentDirection,
1010
createHoverifier,
@@ -30,6 +30,13 @@ describe('Hoverifier', () => {
3030
testcases = dom.createCodeViews()
3131
})
3232

33+
let subscriptions = new Subscription()
34+
35+
afterEach(() => {
36+
subscriptions.unsubscribe()
37+
subscriptions = new Subscription()
38+
})
39+
3340
it('highlights token when hover is fetched (not before)', () => {
3441
for (const codeView of testcases) {
3542
const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b))
@@ -322,6 +329,55 @@ describe('Hoverifier', () => {
322329
}
323330
})
324331

332+
it('hides the hover overlay when the hovered token intersects with a scrollBoundary', async () => {
333+
const gitHubCodeView = testcases[1]
334+
const hoverifier = createHoverifier({
335+
closeButtonClicks: NEVER,
336+
hoverOverlayElements: of(null),
337+
hoverOverlayRerenders: EMPTY,
338+
getHover: createStubHoverProvider({
339+
range: {
340+
start: { line: 4, character: 9 },
341+
end: { line: 4, character: 9 },
342+
},
343+
}),
344+
getActions: createStubActionsProvider(['foo', 'bar']),
345+
pinningEnabled: true,
346+
})
347+
subscriptions.add(hoverifier)
348+
subscriptions.add(
349+
hoverifier.hoverify({
350+
dom: gitHubCodeView,
351+
positionEvents: of(gitHubCodeView.codeView).pipe(
352+
findPositionsFromEvents({ domFunctions: gitHubCodeView })
353+
),
354+
positionJumps: new Subject<PositionJump>(),
355+
resolveContext: () => gitHubCodeView.revSpec,
356+
scrollBoundaries: [gitHubCodeView.codeView.querySelector<HTMLElement>('.sticky-file-header')!],
357+
})
358+
)
359+
360+
gitHubCodeView.codeView.scrollIntoView()
361+
362+
// Click https://sourcegraph.sgdev.org/github.com/gorilla/mux@cb4698366aa625048f3b815af6a0dea8aef9280a/-/blob/mux.go#L5:9
363+
// and wait for the hovered token to be defined.
364+
const hasHoveredToken = hoverifier.hoverStateUpdates
365+
.pipe(takeWhile(({ hoveredTokenElement }) => !isDefined(hoveredTokenElement)))
366+
.toPromise()
367+
dispatchMouseEventAtPositionImpure('click', gitHubCodeView, {
368+
line: 5,
369+
character: 9,
370+
})
371+
await hasHoveredToken
372+
373+
// Scroll down: the hover overlay should get hidden.
374+
const hoverIsHidden = hoverifier.hoverStateUpdates
375+
.pipe(takeWhile(({ hoverOverlayProps }) => isDefined(hoverOverlayProps)))
376+
.toPromise()
377+
gitHubCodeView.getCodeElementFromLineNumber(gitHubCodeView.codeView, 2)!.scrollIntoView({ behavior: 'smooth' })
378+
await hoverIsHidden
379+
})
380+
325381
describe('pinning', () => {
326382
it('unpins upon clicking on a different position', () => {
327383
for (const codeView of testcases) {

src/hoverifier.ts

Lines changed: 126 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Position, Range } from '@sourcegraph/extension-api-types'
22
import { isEqual } from 'lodash'
33
import {
4+
animationFrameScheduler,
45
combineLatest,
56
concat,
67
EMPTY,
@@ -21,15 +22,18 @@ import {
2122
delay,
2223
distinctUntilChanged,
2324
filter,
25+
first,
2426
map,
27+
mapTo,
28+
observeOn,
2529
share,
2630
switchMap,
2731
takeUntil,
2832
withLatestFrom,
2933
} from 'rxjs/operators'
3034
import { Key } from 'ts-key-enum'
3135
import { asError, ErrorLike, isErrorLike } from './errors'
32-
import { scrollIntoCenterIfNeeded } from './helpers'
36+
import { elementOverlaps, scrollIntoCenterIfNeeded } from './helpers'
3337
import { calculateOverlayPosition } from './overlay_position'
3438
import { DiffPart, PositionEvent, SupportedMouseEvent } from './positions'
3539
import { createObservableStateContainer } from './state'
@@ -178,6 +182,15 @@ export interface EventOptions<C extends object> {
178182
adjustPosition?: PositionAdjuster<C>
179183
dom: DOMFunctions
180184
codeViewId: symbol
185+
186+
/**
187+
* An array of elements used to hide the hover overlay if any of them
188+
* overlap with the hovered token. Overlapping is checked in reaction to scroll events.
189+
*
190+
* scrollBoundaries are typically elements with a lower z-index than the hover overlay
191+
* but a higher z-index than the code view, such as a sticky file header.
192+
*/
193+
scrollBoundaries?: HTMLElement[]
181194
}
182195

183196
/**
@@ -607,6 +620,84 @@ export function createHoverifier<C extends object, D, A>({
607620
distinctUntilChanged((a, b) => isEqual(a.position, b.position))
608621
)
609622

623+
/**
624+
* An Observable of scroll events on the document.
625+
*/
626+
const scrollEvents = fromEvent(document, 'scroll').pipe(
627+
observeOn(animationFrameScheduler),
628+
share()
629+
)
630+
631+
/**
632+
* Returns the highlighted range for the given hover result and position.
633+
*
634+
* Returns `undefined` if the hover result is not successful.
635+
*
636+
* Uses the range specified by the hover result if present, or `position` oherwise,
637+
* which will be expanded into a full token in getTokenAtPosition().
638+
*/
639+
const getHighlightedRange = ({
640+
hoverOrError,
641+
position,
642+
}: {
643+
hoverOrError?: typeof LOADING | (HoverAttachment & D) | ErrorLike | null
644+
position: Position | undefined
645+
}): Range | undefined => {
646+
if (hoverOrError && !isErrorLike(hoverOrError) && hoverOrError !== LOADING) {
647+
if (hoverOrError.range) {
648+
// The result is 0-indexed; the code view is treated as 1-indexed.
649+
return {
650+
start: {
651+
line: hoverOrError.range.start.line + 1,
652+
character: hoverOrError.range.start.character + 1,
653+
},
654+
end: {
655+
line: hoverOrError.range.end.line + 1,
656+
character: hoverOrError.range.end.character + 1,
657+
},
658+
}
659+
}
660+
if (position) {
661+
return { start: position, end: position }
662+
}
663+
}
664+
return undefined
665+
}
666+
667+
/**
668+
* Returns an Observable that emits the hover result immediately,
669+
* and will emit a result resetting the hover when the hoveredTokenElement intersects
670+
* with the scrollBoundaries.
671+
*/
672+
const resetOnBoundaryIntersection = ({
673+
hoveredTokenElement,
674+
scrollBoundaries,
675+
...rest
676+
}: Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'hoverOverlayIsFixed'> &
677+
Omit<EventOptions<C>, 'resolveContext' | 'dom'> & { codeView: HTMLElement }): Observable<
678+
Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'hoverOverlayIsFixed'> & { codeView: HTMLElement }
679+
> => {
680+
const result = of({ hoveredTokenElement, ...rest })
681+
if (!hoveredTokenElement || !scrollBoundaries) {
682+
return result
683+
}
684+
return merge(
685+
result,
686+
scrollEvents.pipe(
687+
filter(() => scrollBoundaries.some(elementOverlaps(hoveredTokenElement))),
688+
first(),
689+
mapTo({
690+
...rest,
691+
hoveredTokenElement,
692+
hoverOverlayIsFixed: false,
693+
hoverOrError: undefined,
694+
hoveredToken: undefined,
695+
actionsOrError: undefined,
696+
})
697+
)
698+
)
699+
}
700+
610701
/**
611702
* For every position, emits an Observable with new values for the `hoverOrError` state.
612703
* This is a higher-order Observable (Observable that emits Observables).
@@ -619,6 +710,7 @@ export function createHoverifier<C extends object, D, A>({
619710
adjustPosition?: PositionAdjuster<C>
620711
codeView: HTMLElement
621712
codeViewId: symbol
713+
scrollBoundaries?: HTMLElement[]
622714
hoverOrError?: typeof LOADING | (HoverAttachment & D) | ErrorLike | null
623715
position?: HoveredToken & C
624716
part?: DiffPart
@@ -662,7 +754,7 @@ export function createHoverifier<C extends object, D, A>({
662754
hoverObservables
663755
.pipe(
664756
switchMap(hoverObservable => hoverObservable),
665-
switchMap(({ hoverOrError, position, adjustPosition, ...rest }) => {
757+
switchMap(({ hoverOrError, position, adjustPosition, codeView, part, ...rest }) => {
666758
let pos =
667759
hoverOrError &&
668760
hoverOrError !== LOADING &&
@@ -673,7 +765,13 @@ export function createHoverifier<C extends object, D, A>({
673765
: position
674766

675767
if (!pos) {
676-
return of({ hoverOrError, position: undefined as Position | undefined, ...rest })
768+
return of({
769+
hoverOrError,
770+
codeView,
771+
part,
772+
position: undefined as Position | undefined,
773+
...rest,
774+
})
677775
}
678776

679777
// The requested position is is 0-indexed; the code here is currently 1-indexed
@@ -683,66 +781,51 @@ export function createHoverifier<C extends object, D, A>({
683781
const adjustingPosition = adjustPosition
684782
? from(
685783
adjustPosition({
686-
codeView: rest.codeView,
784+
codeView,
687785
direction: AdjustmentDirection.ActualToCodeView,
688786
position: {
689787
...pos,
690-
part: rest.part,
788+
part,
691789
},
692790
})
693791
)
694792
: of(pos)
695793

696-
return adjustingPosition.pipe(map(position => ({ position, hoverOrError, ...rest })))
794+
return adjustingPosition.pipe(
795+
map(position => ({ position, hoverOrError, codeView, part, ...rest }))
796+
)
797+
}),
798+
switchMap(({ scrollBoundaries, hoverOrError, position, codeView, codeViewId, dom, part }) => {
799+
const highlightedRange = getHighlightedRange({ hoverOrError, position })
800+
const hoveredTokenElement = highlightedRange
801+
? getTokenAtPosition(codeView, highlightedRange.start, dom, part, tokenize)
802+
: undefined
803+
return resetOnBoundaryIntersection({
804+
scrollBoundaries,
805+
codeViewId,
806+
codeView,
807+
highlightedRange,
808+
hoverOrError,
809+
hoveredTokenElement,
810+
hoverOverlayPosition: undefined,
811+
})
697812
})
698813
)
699-
.subscribe(({ hoverOrError, position, codeView, codeViewId, dom, part }) => {
700-
// Update the highlighted token if the hover result is successful. If the hover result specifies a
701-
// range, use that; otherwise use the hover position (which will be expanded into a full token in
702-
// getTokenAtPosition).
703-
let highlightedRange: Range | undefined
704-
if (hoverOrError && !isErrorLike(hoverOrError) && hoverOrError !== LOADING) {
705-
if (hoverOrError.range) {
706-
// The result is 0-indexed; the code view is treated as 1-indexed.
707-
highlightedRange = {
708-
start: {
709-
line: hoverOrError.range.start.line + 1,
710-
character: hoverOrError.range.start.character + 1,
711-
},
712-
end: {
713-
line: hoverOrError.range.end.line + 1,
714-
character: hoverOrError.range.end.character + 1,
715-
},
716-
}
717-
} else if (position) {
718-
highlightedRange = { start: position, end: position }
719-
}
720-
}
721-
814+
.subscribe(({ codeView, highlightedRange, hoveredTokenElement, ...rest }) => {
722815
container.update({
723-
codeViewId,
724-
hoverOrError,
725816
highlightedRange,
726-
// Reset the hover position, it's gonna be repositioned after the hover was rendered
727-
hoverOverlayPosition: undefined,
817+
hoveredTokenElement,
818+
...rest,
728819
})
729-
730820
// Ensure the previously highlighted range is not highlighted and the new highlightedRange (if any)
731821
// is highlighted.
732822
const currentHighlighted = codeView.querySelector('.selection-highlight')
733823
if (currentHighlighted) {
734824
currentHighlighted.classList.remove('selection-highlight')
735825
}
736-
if (!highlightedRange) {
737-
container.update({ hoveredTokenElement: undefined })
738-
return
739-
}
740-
const token = getTokenAtPosition(codeView, highlightedRange.start, dom, part, tokenize)
741-
container.update({ hoveredTokenElement: token })
742-
if (!token) {
743-
return
826+
if (hoveredTokenElement) {
827+
hoveredTokenElement.classList.add('selection-highlight')
744828
}
745-
token.classList.add('selection-highlight')
746829
})
747830
)
748831

testdata/github/generate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function generateGithubCodeTable(lines: string[]): string {
1919
</style>
2020
<div class="container">
2121
<div class="file">
22+
<div class="file-header sticky-file-header"></div>
2223
<div itemprop="text" class="blob-wrapper data">
2324
<table><tbody>${code}</tbody></table>
2425
</div>

testdata/github/styles.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010
color: #24292e;
1111
}
1212

13+
.github-testcase .sticky-file-header {
14+
position: sticky;
15+
z-index: 6;
16+
top: 0px;
17+
}
18+
19+
.github-testcase .file-header {
20+
height: 60px;
21+
padding: 5px 10px;
22+
background-color: #fafbfc;
23+
border-bottom: 1px solid #e1e4e8;
24+
border-top-left-radius: 2px;
25+
border-top-right-radius: 2px;
26+
}
27+
1328
.github-testcase .container {
1429
width: 980px;
1530
margin-right: auto;

0 commit comments

Comments
 (0)