Add useScrollIntoView hook
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
|
||||
@@ -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 } : {})}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
29
extensions/ql-vscode/src/view/results/useScrollIntoView.tsx
Normal file
29
extensions/ql-vscode/src/view/results/useScrollIntoView.tsx
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user