Add useScrollIntoView hook

This commit is contained in:
Robert
2023-08-22 12:25:55 +01:00
parent d777427c0a
commit 610c936690
8 changed files with 50 additions and 88 deletions

View File

@@ -14,13 +14,13 @@ import {
SarifInterpretationData,
} from "../../common/interface-types";
import { parseSarifLocation, isNoLocation } from "../../common/sarif-utils";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
import { sendTelemetry } from "../common/telemetry";
import { AlertTableHeader } from "./AlertTableHeader";
import { AlertTableNoResults } from "./AlertTableNoResults";
import { AlertTableTruncatedMessage } from "./AlertTableTruncatedMessage";
import { AlertTableResultRow } from "./AlertTableResultRow";
import { useCallback, useEffect, useRef, useState } from "react";
import { useScrollIntoView } from "./useScrollIntoView";
type AlertTableProps = ResultTableProps & {
resultSet: InterpretedResultSet<SarifInterpretationData>;
@@ -29,17 +29,14 @@ type AlertTableProps = ResultTableProps & {
export function AlertTable(props: AlertTableProps) {
const { databaseUri, resultSet } = props;
const scroller = useRef<ScrollIntoViewHelper | undefined>(undefined);
if (scroller.current === undefined) {
scroller.current = new ScrollIntoViewHelper();
}
useEffect(() => scroller.current?.update());
const [expanded, setExpanded] = useState<Set<string>>(new Set<string>());
const [selectedItem, setSelectedItem] = useState<Keys.ResultKey | undefined>(
undefined,
);
const selectedItemRef = useRef<any>();
useScrollIntoView(selectedItem, selectedItemRef);
/**
* Given a list of `keys`, toggle the first, and if we 'open' the
* first item, open all the rest as well. This mimics vscode's file
@@ -162,7 +159,6 @@ export function AlertTable(props: AlertTableProps) {
newExpanded.delete(Keys.keyToString(selectedItem));
}
}
scroller.current?.scrollIntoViewOnNextUpdate();
setExpanded(newExpanded);
setSelectedItem(key);
},
@@ -203,11 +199,11 @@ export function AlertTable(props: AlertTableProps) {
resultIndex={resultIndex}
expanded={expanded}
selectedItem={selectedItem}
selectedItemRef={selectedItemRef}
databaseUri={databaseUri}
sourceLocationPrefix={sourceLocationPrefix}
updateSelectionCallback={updateSelectionCallback}
toggleExpanded={toggle}
scroller={scroller.current}
/>
),
)}

View File

@@ -3,7 +3,6 @@ import * as Sarif from "sarif";
import * as Keys from "./result-keys";
import { SarifLocation } from "./locations/SarifLocation";
import { selectableZebraStripe } from "./result-table-utils";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
import { useCallback, useMemo } from "react";
interface Props {
@@ -12,12 +11,12 @@ interface Props {
pathIndex: number;
resultIndex: number;
selectedItem: undefined | Keys.ResultKey;
selectedItemRef: React.RefObject<any>;
databaseUri: string;
sourceLocationPrefix: string;
updateSelectionCallback: (
resultKey: Keys.PathNode | Keys.Result | undefined,
) => void;
scroller?: ScrollIntoViewHelper;
}
export function AlertTablePathNodeRow(props: Props) {
@@ -27,10 +26,10 @@ export function AlertTablePathNodeRow(props: Props) {
pathIndex,
resultIndex,
selectedItem,
selectedItemRef,
databaseUri,
sourceLocationPrefix,
updateSelectionCallback,
scroller,
} = props;
const pathNodeKey: Keys.PathNode = useMemo(
@@ -51,7 +50,7 @@ export function AlertTablePathNodeRow(props: Props) {
const zebraIndex = resultIndex + stepIndex;
return (
<tr
ref={scroller?.ref(isSelected)}
ref={isSelected ? selectedItemRef : undefined}
className={isSelected ? "vscode-codeql__selected-path-node" : undefined}
>
<td className="vscode-codeql__icon-cell">

View File

@@ -2,7 +2,6 @@ import * as React from "react";
import * as Sarif from "sarif";
import * as Keys from "./result-keys";
import { selectableZebraStripe } from "./result-table-utils";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
import { AlertTablePathNodeRow } from "./AlertTablePathNodeRow";
import { AlertTableDropdownIndicatorCell } from "./AlertTableDropdownIndicatorCell";
import { useCallback, useMemo } from "react";
@@ -13,13 +12,13 @@ interface Props {
resultIndex: number;
currentPathExpanded: boolean;
selectedItem: undefined | Keys.ResultKey;
selectedItemRef: React.RefObject<any>;
databaseUri: string;
sourceLocationPrefix: string;
updateSelectionCallback: (
resultKey: Keys.PathNode | Keys.Result | undefined,
) => void;
toggleExpanded: (e: React.MouseEvent, keys: Keys.ResultKey[]) => void;
scroller?: ScrollIntoViewHelper;
}
export function AlertTablePathRow(props: Props) {
@@ -29,8 +28,8 @@ export function AlertTablePathRow(props: Props) {
resultIndex,
currentPathExpanded,
selectedItem,
selectedItemRef,
toggleExpanded,
scroller,
} = props;
const pathKey = useMemo(
@@ -50,7 +49,7 @@ export function AlertTablePathRow(props: Props) {
return (
<>
<tr
ref={scroller?.ref(isPathSpecificallySelected)}
ref={isPathSpecificallySelected ? selectedItemRef : undefined}
{...selectableZebraStripe(isPathSpecificallySelected, resultIndex)}
>
<td className="vscode-codeql__icon-cell">

View File

@@ -2,7 +2,6 @@ import * as React from "react";
import * as Sarif from "sarif";
import * as Keys from "./result-keys";
import { info, listUnordered } from "./octicons";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
import { selectableZebraStripe } from "./result-table-utils";
import { AlertTableDropdownIndicatorCell } from "./AlertTableDropdownIndicatorCell";
import { useCallback, useMemo } from "react";
@@ -15,13 +14,13 @@ interface Props {
resultIndex: number;
expanded: Set<string>;
selectedItem: undefined | Keys.ResultKey;
selectedItemRef: React.RefObject<any>;
databaseUri: string;
sourceLocationPrefix: string;
updateSelectionCallback: (
resultKey: Keys.PathNode | Keys.Result | undefined,
) => void;
toggleExpanded: (e: React.MouseEvent, keys: Keys.ResultKey[]) => void;
scroller?: ScrollIntoViewHelper;
}
export function AlertTableResultRow(props: Props) {
@@ -30,11 +29,11 @@ export function AlertTableResultRow(props: Props) {
resultIndex,
expanded,
selectedItem,
selectedItemRef,
databaseUri,
sourceLocationPrefix,
updateSelectionCallback,
toggleExpanded,
scroller,
} = props;
const resultKey: Keys.Result = useMemo(
@@ -81,7 +80,7 @@ export function AlertTableResultRow(props: Props) {
return (
<>
<tr
ref={scroller?.ref(resultRowIsSelected)}
ref={resultRowIsSelected ? selectedItemRef : undefined}
{...selectableZebraStripe(resultRowIsSelected, resultIndex)}
>
{result.codeFlows === undefined ? (

View File

@@ -13,10 +13,10 @@ import RawTableRow from "./RawTableRow";
import { ResultRow } from "../../common/bqrs-cli-types";
import { onNavigation } from "./ResultsApp";
import { tryGetResolvableLocation } from "../../common/bqrs-utils";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
import { sendTelemetry } from "../common/telemetry";
import { assertNever } from "../../common/helpers-pure";
import { EmptyQueryResultsMessage } from "./EmptyQueryResultsMessage";
import { useScrollIntoView } from "./useScrollIntoView";
type RawTableProps = {
databaseUri: string;
@@ -38,11 +38,8 @@ export function RawTable({
}: RawTableProps) {
const [selectedItem, setSelectedItem] = useState<TableItem | undefined>();
const scroller = useRef<ScrollIntoViewHelper | undefined>(undefined);
if (scroller.current === undefined) {
scroller.current = new ScrollIntoViewHelper();
}
useEffect(() => scroller.current?.update());
const selectedItemRef = useRef<any>();
useScrollIntoView(selectedItem, selectedItemRef);
const setSelection = useCallback((row: number, column: number): void => {
setSelectedItem({ row, column });
@@ -76,11 +73,10 @@ export function RawTable({
jumpToLocation(location, databaseUri);
}
}
scroller.current?.scrollIntoViewOnNextUpdate();
return { row: nextRow, column: nextColumn };
});
},
[databaseUri, resultSet, scroller],
[databaseUri, resultSet],
);
const handleNavigationEvent = useCallback(
@@ -140,7 +136,7 @@ export function RawTable({
selectedItem?.row === rowIndex ? selectedItem?.column : undefined
}
onSelected={setSelection}
scroller={scroller.current}
selectedItemRef={selectedItemRef}
/>
));

View File

@@ -2,7 +2,6 @@ import * as React from "react";
import { ResultRow } from "../../common/bqrs-cli-types";
import { selectedRowClassName, zebraStripe } from "./result-table-utils";
import RawTableValue from "./RawTableValue";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
interface Props {
rowIndex: number;
@@ -10,8 +9,8 @@ interface Props {
databaseUri: string;
className?: string;
selectedColumn?: number;
selectedItemRef?: React.Ref<any>;
onSelected?: (row: number, column: number) => void;
scroller?: ScrollIntoViewHelper;
}
export default function RawTableRow(props: Props) {
@@ -26,7 +25,7 @@ export default function RawTableRow(props: Props) {
const isSelected = props.selectedColumn === columnIndex;
return (
<td
ref={props.scroller?.ref(isSelected)}
ref={isSelected ? props.selectedItemRef : undefined}
key={columnIndex}
{...(isSelected ? { className: selectedRowClassName } : {})}
>

View File

@@ -1,55 +0,0 @@
import { createRef } from "react";
/**
* Some book-keeping needed to scroll a specific HTML element into view in a React component.
*/
export class ScrollIntoViewHelper {
private selectedElementRef = createRef<HTMLElement | any>(); // need 'any' to work around typing bug in React
private shouldScrollIntoView = true;
/**
* If `isSelected` is true, gets the `ref={}` attribute to use for an element that we might want to scroll into view.
*/
public ref(isSelected: boolean) {
return isSelected ? this.selectedElementRef : undefined;
}
/**
* Causes the element whose `ref={}` was set to be scrolled into view after the next render.
*/
public scrollIntoViewOnNextUpdate() {
this.shouldScrollIntoView = true;
}
/**
* Should be called from `componentDidUpdate` and `componentDidMount`.
*
* Scrolls the component into view if requested.
*/
public update() {
if (!this.shouldScrollIntoView) {
return;
}
this.shouldScrollIntoView = false;
const element = this.selectedElementRef.current as HTMLElement | null;
if (element == null) {
return;
}
const rect = element.getBoundingClientRect();
// The selected item's bounding box might be on screen, but hidden underneath the sticky header
// which overlaps the table view. As a workaround we hardcode a fixed distance from the top which
// we consider to be obscured. It does not have to exact, as it's just a threshold for when to scroll.
const heightOfStickyHeader = 30;
if (rect.top < heightOfStickyHeader || rect.bottom > window.innerHeight) {
element.scrollIntoView({
block: "center", // vertically align to center
});
}
if (rect.left < 0 || rect.right > window.innerWidth) {
element.scrollIntoView({
block: "nearest",
inline: "nearest", // horizontally align as little as possible
});
}
}
}

View File

@@ -0,0 +1,29 @@
import { RefObject, useEffect } from "react";
export function useScrollIntoView<T>(
selectedElement: T | undefined,
selectedElementRef: RefObject<any>,
) {
useEffect(() => {
const element = selectedElementRef.current as HTMLElement | undefined;
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
// The selected item's bounding box might be on screen, but hidden underneath the sticky header
// which overlaps the table view. As a workaround we hardcode a fixed distance from the top which
// we consider to be obscured. It does not have to exact, as it's just a threshold for when to scroll.
const heightOfStickyHeader = 30;
if (rect.top < heightOfStickyHeader || rect.bottom > window.innerHeight) {
element.scrollIntoView({
block: "center", // vertically align to center
});
}
if (rect.left < 0 || rect.right > window.innerWidth) {
element.scrollIntoView({
block: "nearest",
inline: "nearest", // horizontally align as little as possible
});
}
}, [selectedElement, selectedElementRef]);
}