Skip to content
This repository was archived by the owner on Nov 25, 2021. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,17 @@ export const scrollIntoCenterIfNeeded = (container: HTMLElement, content: HTMLEl
container.scrollTop = scrollTop
}
}

/**
* Returns a curried function that returns `true` if `e1` and `e2` overlap.
*/
export const elementOverlaps = (e1: HTMLElement) => (e2: HTMLElement): boolean => {
const e1Rect = e1.getBoundingClientRect()
const e2Rect = e2.getBoundingClientRect()
return !(
e1Rect.right < e2Rect.left ||
e1Rect.left > e2Rect.right ||
e1Rect.bottom < e2Rect.top ||
e1Rect.top > e2Rect.bottom
)
}
60 changes: 58 additions & 2 deletions src/hoverifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Range } from '@sourcegraph/extension-api-types'
import { isEqual } from 'lodash'
import { EMPTY, NEVER, Observable, of, Subject, Subscription } from 'rxjs'
import { delay, distinctUntilChanged, filter, first, map } from 'rxjs/operators'
import { delay, distinctUntilChanged, filter, first, map, takeWhile } from 'rxjs/operators'
import { TestScheduler } from 'rxjs/testing'
import { ErrorLike } from './errors'
import { propertyIsDefined } from './helpers'
import { isDefined, propertyIsDefined } from './helpers'
import {
AdjustmentDirection,
createHoverifier,
Expand All @@ -30,6 +30,13 @@ describe('Hoverifier', () => {
testcases = dom.createCodeViews()
})

let subscriptions = new Subscription()

afterEach(() => {
subscriptions.unsubscribe()
subscriptions = new Subscription()
})

it('highlights token when hover is fetched (not before)', () => {
for (const codeView of testcases) {
const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b))
Expand Down Expand Up @@ -322,6 +329,55 @@ describe('Hoverifier', () => {
}
})

it('hides the hover overlay when the hovered token intersects with a scrollBoundary', async () => {
const gitHubCodeView = testcases[1]
const hoverifier = createHoverifier({
closeButtonClicks: NEVER,
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider({
range: {
start: { line: 4, character: 9 },
end: { line: 4, character: 9 },
},
}),
getActions: createStubActionsProvider(['foo', 'bar']),
pinningEnabled: true,
})
subscriptions.add(hoverifier)
subscriptions.add(
hoverifier.hoverify({
dom: gitHubCodeView,
positionEvents: of(gitHubCodeView.codeView).pipe(
findPositionsFromEvents({ domFunctions: gitHubCodeView })
),
positionJumps: new Subject<PositionJump>(),
resolveContext: () => gitHubCodeView.revSpec,
scrollBoundaries: [gitHubCodeView.codeView.querySelector<HTMLElement>('.sticky-file-header')!],
})
)

gitHubCodeView.codeView.scrollIntoView()

// Click https://sourcegraph.sgdev.org/github.com/gorilla/mux@cb4698366aa625048f3b815af6a0dea8aef9280a/-/blob/mux.go#L5:9
// and wait for the hovered token to be defined.
const hasHoveredToken = hoverifier.hoverStateUpdates
.pipe(takeWhile(({ hoveredTokenElement }) => !isDefined(hoveredTokenElement)))
.toPromise()
dispatchMouseEventAtPositionImpure('click', gitHubCodeView, {
line: 5,
character: 9,
})
await hasHoveredToken

// Scroll down: the hover overlay should get hidden.
const hoverIsHidden = hoverifier.hoverStateUpdates
.pipe(takeWhile(({ hoverOverlayProps }) => isDefined(hoverOverlayProps)))
.toPromise()
gitHubCodeView.getCodeElementFromLineNumber(gitHubCodeView.codeView, 2)!.scrollIntoView({ behavior: 'smooth' })
await hoverIsHidden
})

describe('pinning', () => {
it('unpins upon clicking on a different position', () => {
for (const codeView of testcases) {
Expand Down
169 changes: 126 additions & 43 deletions src/hoverifier.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Position, Range } from '@sourcegraph/extension-api-types'
import { isEqual } from 'lodash'
import {
animationFrameScheduler,
combineLatest,
concat,
EMPTY,
Expand All @@ -21,15 +22,18 @@ import {
delay,
distinctUntilChanged,
filter,
first,
map,
mapTo,
observeOn,
share,
switchMap,
takeUntil,
withLatestFrom,
} from 'rxjs/operators'
import { Key } from 'ts-key-enum'
import { asError, ErrorLike, isErrorLike } from './errors'
import { scrollIntoCenterIfNeeded } from './helpers'
import { elementOverlaps, scrollIntoCenterIfNeeded } from './helpers'
import { calculateOverlayPosition } from './overlay_position'
import { DiffPart, PositionEvent, SupportedMouseEvent } from './positions'
import { createObservableStateContainer } from './state'
Expand Down Expand Up @@ -178,6 +182,15 @@ export interface EventOptions<C extends object> {
adjustPosition?: PositionAdjuster<C>
dom: DOMFunctions
codeViewId: symbol

/**
* An array of elements used to hide the hover overlay if any of them
* overlap with the hovered token. Overlapping is checked in reaction to scroll events.
*
* scrollBoundaries are typically 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.
*/
scrollBoundaries?: HTMLElement[]
}

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

/**
* An Observable of scroll events on the document.
*/
const scrollEvents = fromEvent(document, 'scroll').pipe(
observeOn(animationFrameScheduler),
share()
)

/**
* Returns the highlighted range for the given hover result and position.
*
* Returns `undefined` if the hover result is not successful.
*
* Uses the range specified by the hover result if present, or `position` oherwise,
* which will be expanded into a full token in getTokenAtPosition().
*/
const getHighlightedRange = ({
hoverOrError,
position,
}: {
hoverOrError?: typeof LOADING | (HoverAttachment & D) | ErrorLike | null
position: Position | undefined
}): Range | undefined => {
if (hoverOrError && !isErrorLike(hoverOrError) && hoverOrError !== LOADING) {
if (hoverOrError.range) {
// The result is 0-indexed; the code view is treated as 1-indexed.
return {
start: {
line: hoverOrError.range.start.line + 1,
character: hoverOrError.range.start.character + 1,
},
end: {
line: hoverOrError.range.end.line + 1,
character: hoverOrError.range.end.character + 1,
},
}
}
if (position) {
return { start: position, end: position }
}
}
return undefined
}

/**
* Returns an Observable that emits the hover result immediately,
* and will emit a result resetting the hover when the hoveredTokenElement intersects
* with the scrollBoundaries.
*/
const resetOnBoundaryIntersection = ({
hoveredTokenElement,
scrollBoundaries,
...rest
}: Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'hoverOverlayIsFixed'> &
Omit<EventOptions<C>, 'resolveContext' | 'dom'> & { codeView: HTMLElement }): Observable<
Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'hoverOverlayIsFixed'> & { codeView: HTMLElement }
> => {
const result = of({ hoveredTokenElement, ...rest })
if (!hoveredTokenElement || !scrollBoundaries) {
return result
}
return merge(
result,
scrollEvents.pipe(
filter(() => scrollBoundaries.some(elementOverlaps(hoveredTokenElement))),
first(),
mapTo({
...rest,
hoveredTokenElement,
hoverOverlayIsFixed: false,
hoverOrError: undefined,
hoveredToken: undefined,
actionsOrError: undefined,
})
)
)
}

/**
* For every position, emits an Observable with new values for the `hoverOrError` state.
* This is a higher-order Observable (Observable that emits Observables).
Expand All @@ -619,6 +710,7 @@ export function createHoverifier<C extends object, D, A>({
adjustPosition?: PositionAdjuster<C>
codeView: HTMLElement
codeViewId: symbol
scrollBoundaries?: HTMLElement[]
hoverOrError?: typeof LOADING | (HoverAttachment & D) | ErrorLike | null
position?: HoveredToken & C
part?: DiffPart
Expand Down Expand Up @@ -662,7 +754,7 @@ export function createHoverifier<C extends object, D, A>({
hoverObservables
.pipe(
switchMap(hoverObservable => hoverObservable),
switchMap(({ hoverOrError, position, adjustPosition, ...rest }) => {
switchMap(({ hoverOrError, position, adjustPosition, codeView, part, ...rest }) => {
let pos =
hoverOrError &&
hoverOrError !== LOADING &&
Expand All @@ -673,7 +765,13 @@ export function createHoverifier<C extends object, D, A>({
: position

if (!pos) {
return of({ hoverOrError, position: undefined as Position | undefined, ...rest })
return of({
hoverOrError,
codeView,
part,
position: undefined as Position | undefined,
...rest,
})
}

// The requested position is is 0-indexed; the code here is currently 1-indexed
Expand All @@ -683,66 +781,51 @@ export function createHoverifier<C extends object, D, A>({
const adjustingPosition = adjustPosition
? from(
adjustPosition({
codeView: rest.codeView,
codeView,
direction: AdjustmentDirection.ActualToCodeView,
position: {
...pos,
part: rest.part,
part,
},
})
)
: of(pos)

return adjustingPosition.pipe(map(position => ({ position, hoverOrError, ...rest })))
return adjustingPosition.pipe(
map(position => ({ position, hoverOrError, codeView, part, ...rest }))
)
}),
switchMap(({ scrollBoundaries, hoverOrError, position, codeView, codeViewId, dom, part }) => {
const highlightedRange = getHighlightedRange({ hoverOrError, position })
const hoveredTokenElement = highlightedRange
? getTokenAtPosition(codeView, highlightedRange.start, dom, part, tokenize)
: undefined
return resetOnBoundaryIntersection({
scrollBoundaries,
codeViewId,
codeView,
highlightedRange,
hoverOrError,
hoveredTokenElement,
hoverOverlayPosition: undefined,
})
})
)
.subscribe(({ hoverOrError, position, codeView, codeViewId, dom, part }) => {
// Update the highlighted token if the hover result is successful. If the hover result specifies a
// range, use that; otherwise use the hover position (which will be expanded into a full token in
// getTokenAtPosition).
let highlightedRange: Range | undefined
if (hoverOrError && !isErrorLike(hoverOrError) && hoverOrError !== LOADING) {
if (hoverOrError.range) {
// The result is 0-indexed; the code view is treated as 1-indexed.
highlightedRange = {
start: {
line: hoverOrError.range.start.line + 1,
character: hoverOrError.range.start.character + 1,
},
end: {
line: hoverOrError.range.end.line + 1,
character: hoverOrError.range.end.character + 1,
},
}
} else if (position) {
highlightedRange = { start: position, end: position }
}
}

.subscribe(({ codeView, highlightedRange, hoveredTokenElement, ...rest }) => {
container.update({
codeViewId,
hoverOrError,
highlightedRange,
// Reset the hover position, it's gonna be repositioned after the hover was rendered
hoverOverlayPosition: undefined,
hoveredTokenElement,
...rest,
})

// Ensure the previously highlighted range is not highlighted and the new highlightedRange (if any)
// is highlighted.
const currentHighlighted = codeView.querySelector('.selection-highlight')
if (currentHighlighted) {
currentHighlighted.classList.remove('selection-highlight')
}
if (!highlightedRange) {
container.update({ hoveredTokenElement: undefined })
return
}
const token = getTokenAtPosition(codeView, highlightedRange.start, dom, part, tokenize)
container.update({ hoveredTokenElement: token })
if (!token) {
return
if (hoveredTokenElement) {
hoveredTokenElement.classList.add('selection-highlight')
}
token.classList.add('selection-highlight')
})
)

Expand Down
1 change: 1 addition & 0 deletions testdata/github/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function generateGithubCodeTable(lines: string[]): string {
</style>
<div class="container">
<div class="file">
<div class="file-header sticky-file-header"></div>
<div itemprop="text" class="blob-wrapper data">
<table><tbody>${code}</tbody></table>
</div>
Expand Down
15 changes: 15 additions & 0 deletions testdata/github/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@
color: #24292e;
}

.github-testcase .sticky-file-header {
position: sticky;
z-index: 6;
top: 0px;
}

.github-testcase .file-header {
height: 60px;
padding: 5px 10px;
background-color: #fafbfc;
border-bottom: 1px solid #e1e4e8;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}

.github-testcase .container {
width: 980px;
margin-right: auto;
Expand Down