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

View File

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

View File

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

View File

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

View File

@@ -13,10 +13,10 @@ import RawTableRow from "./RawTableRow";
import { ResultRow } from "../../common/bqrs-cli-types"; import { ResultRow } from "../../common/bqrs-cli-types";
import { onNavigation } from "./ResultsApp"; import { onNavigation } from "./ResultsApp";
import { tryGetResolvableLocation } from "../../common/bqrs-utils"; import { tryGetResolvableLocation } from "../../common/bqrs-utils";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
import { sendTelemetry } from "../common/telemetry"; import { sendTelemetry } from "../common/telemetry";
import { assertNever } from "../../common/helpers-pure"; import { assertNever } from "../../common/helpers-pure";
import { EmptyQueryResultsMessage } from "./EmptyQueryResultsMessage"; import { EmptyQueryResultsMessage } from "./EmptyQueryResultsMessage";
import { useScrollIntoView } from "./useScrollIntoView";
type RawTableProps = { type RawTableProps = {
databaseUri: string; databaseUri: string;
@@ -38,11 +38,8 @@ export function RawTable({
}: RawTableProps) { }: RawTableProps) {
const [selectedItem, setSelectedItem] = useState<TableItem | undefined>(); const [selectedItem, setSelectedItem] = useState<TableItem | undefined>();
const scroller = useRef<ScrollIntoViewHelper | undefined>(undefined); const selectedItemRef = useRef<any>();
if (scroller.current === undefined) { useScrollIntoView(selectedItem, selectedItemRef);
scroller.current = new ScrollIntoViewHelper();
}
useEffect(() => scroller.current?.update());
const setSelection = useCallback((row: number, column: number): void => { const setSelection = useCallback((row: number, column: number): void => {
setSelectedItem({ row, column }); setSelectedItem({ row, column });
@@ -76,11 +73,10 @@ export function RawTable({
jumpToLocation(location, databaseUri); jumpToLocation(location, databaseUri);
} }
} }
scroller.current?.scrollIntoViewOnNextUpdate();
return { row: nextRow, column: nextColumn }; return { row: nextRow, column: nextColumn };
}); });
}, },
[databaseUri, resultSet, scroller], [databaseUri, resultSet],
); );
const handleNavigationEvent = useCallback( const handleNavigationEvent = useCallback(
@@ -140,7 +136,7 @@ export function RawTable({
selectedItem?.row === rowIndex ? selectedItem?.column : undefined selectedItem?.row === rowIndex ? selectedItem?.column : undefined
} }
onSelected={setSelection} 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 { ResultRow } from "../../common/bqrs-cli-types";
import { selectedRowClassName, zebraStripe } from "./result-table-utils"; import { selectedRowClassName, zebraStripe } from "./result-table-utils";
import RawTableValue from "./RawTableValue"; import RawTableValue from "./RawTableValue";
import { ScrollIntoViewHelper } from "./scroll-into-view-helper";
interface Props { interface Props {
rowIndex: number; rowIndex: number;
@@ -10,8 +9,8 @@ interface Props {
databaseUri: string; databaseUri: string;
className?: string; className?: string;
selectedColumn?: number; selectedColumn?: number;
selectedItemRef?: React.Ref<any>;
onSelected?: (row: number, column: number) => void; onSelected?: (row: number, column: number) => void;
scroller?: ScrollIntoViewHelper;
} }
export default function RawTableRow(props: Props) { export default function RawTableRow(props: Props) {
@@ -26,7 +25,7 @@ export default function RawTableRow(props: Props) {
const isSelected = props.selectedColumn === columnIndex; const isSelected = props.selectedColumn === columnIndex;
return ( return (
<td <td
ref={props.scroller?.ref(isSelected)} ref={isSelected ? props.selectedItemRef : undefined}
key={columnIndex} key={columnIndex}
{...(isSelected ? { className: selectedRowClassName } : {})} {...(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]);
}