/* * Copyright 2017 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { AbstractComponent, Utils as CoreUtils, type Props, setRef } from "@blueprintjs/core"; import * as Classes from "../common/classes"; import { Grid } from "../common/grid"; import * as ScrollUtils from "../common/internal/scrollUtils"; import { Utils } from "../common/utils"; import type { TableLoadingOption } from "../regions"; import { QuadrantType, TableQuadrant } from "./tableQuadrant"; import { TableQuadrantStackCache } from "./tableQuadrantStackCache"; interface QuadrantRefMap { columnHeader?: T; menu?: T; quadrant?: T; rowHeader?: T; scrollContainer?: T; } type QuadrantRefHandler = React.Ref; type QuadrantRefs = QuadrantRefMap; type QuadrantRefHandlers = QuadrantRefMap; interface TableQuadrantStackProps extends Props { /** * A callback that receives a `ref` to the main quadrant's table-body element. */ bodyRef?: React.Ref; /** * A callback that receives a `ref` to the main quadrant's column-header container. */ columnHeaderRef?: React.Ref; /** * The grid computes sizes of cells, rows, or columns from the * configurable `columnWidths` and `rowHeights`. */ grid: Grid; /** * An optional callback for reacting to column-resize events. */ handleColumnResizeGuide?: (verticalGuides: number[]) => void; /** * An optional callback for reacting to column-reordering events. */ handleColumnsReordering?: (verticalGuides: number[]) => void; /** * An optional callback for reacting to row-resize events. */ handleRowResizeGuide?: (horizontalGuides: number[]) => void; /** * An optional callback for reacting to column-reordering events. */ handleRowsReordering?: (horizontalGuides: number[]) => void; /** * Whether horizontal scrolling is currently disabled. * * @default false */ isHorizontalScrollDisabled?: boolean; /** * If `false`, hides the row headers and settings menu. Affects the layout * of the table, so we need to know when this changes in order to * synchronize quadrant sizes properly. * * REQUIRES QUADRANT RESYNC * * @default true */ enableRowHeader?: boolean; /** * Whether vertical scrolling is currently disabled. * * @default false */ isVerticalScrollDisabled?: boolean; /** * A list of `TableLoadingOption`. Loading cells may have different sizes * from potentially custom cells in the header or body, so we need to know * when the loading states change in order to synchronize quadrant sizes * properly. * * REQUIRES QUADRANT RESYNC */ loadingOptions?: TableLoadingOption[]; /** * The number of columns. Affects the layout of the table, so we need to * know when this changes in order to synchronize quadrant sizes properly. * * REQUIRES QUADRANT RESYNC */ numColumns?: number; /** * The number of frozen columns. Affects the layout of the table, so we need * to know when this changes in order to synchronize quadrant sizes * properly. * * REQUIRES QUADRANT RESYNC */ numFrozenColumns: number; /** * The number of frozen rows. Affects the layout of the table, so we need to * know when this changes in order to synchronize quadrant sizes properly. * * REQUIRES QUADRANT RESYNC */ numFrozenRows: number; /** * The number of rows. Affects the layout of the table, so we need to know * when this changes in order to synchronize quadrant sizes properly. * * REQUIRES QUADRANT RESYNC */ numRows?: number; /** * An optional callback invoked the quadrants are scrolled. */ onScroll?: React.EventHandler>; /** * A callback that receives a `ref` to the main-quadrant element. */ quadrantRef?: React.Ref; /** * A callback that renders either all of or just frozen sections of the table body. * May return undefined if the table is not attached to the DOM yet. */ bodyRenderer: ( quadrantType: QuadrantType, showFrozenRowsOnly?: boolean, showFrozenColumnsOnly?: boolean, ) => React.JSX.Element | undefined; /** * A callback that renders either all of or just the frozen section of the column header. * May return undefined if the table is not attached to the DOM yet. */ columnHeaderRenderer?: ( refHandler: React.Ref, resizeHandler: (verticalGuides: number[] | null) => void, reorderingHandler: (oldIndex: number, newIndex: number, length: number) => void, showFrozenColumnsOnly?: boolean, ) => React.JSX.Element | undefined; /** * A callback that renders the table menu (the rectangle in the top-left corner). */ menuRenderer?: (refHandler: React.Ref | undefined) => React.JSX.Element; /** * A callback that renders either all of or just the frozen section of the row header. * May return undefined if the table is not attached to the DOM yet. */ rowHeaderRenderer?: ( refHandler: React.Ref, resizeHandler: (verticalGuides: number[] | null) => void, reorderingHandler: (oldIndex: number, newIndex: number, length: number) => void, showFrozenRowsOnly?: boolean, ) => React.JSX.Element | undefined; renderScrollIndicatorOverlay?: ( scrollBarWidth: number, columnHeaderHeight: number, ) => React.JSX.Element | undefined; /** * A callback that receives a `ref` to the main quadrant's row-header container. */ rowHeaderRef?: React.Ref; /** * A callback that receives a `ref` to the main quadrant's scroll-container element. */ scrollContainerRef?: React.Ref; /** * Whether "scroll" and "wheel" events should be throttled using * requestAnimationFrame. Disabling this can be useful for unit testing, * because tests can then be synchronous. * * @default true */ throttleScrolling?: boolean; /** * The amount of time in milliseconds the component should wait before * synchronizing quadrant sizes and offsets after the user has stopped * scrolling. If this value is negative, the updates will happen * synchronously (this is helpful for unit testing). * * @default 500 */ viewSyncDelay?: number; /** * If `true`, adds an interaction bar on top of all column header cells, and * moves interaction triggers into it. Affects the layout of the table, so * we need to know when this changes in order to synchronize quadrant sizes * properly. * * This value defaults to `undefined` so that, by default, it won't override * the `enableColumnInteractionBar` values that you might have provided directly to * each ``. * * REQUIRES QUADRANT RESYNC * * @default undefined */ enableColumnInteractionBar?: boolean; /** * Flag indicating that both the column headers (if present) * and row headers (if present) have been rendered and mounted, including any * custom renderers which may affect quadrant layout measurements. */ didHeadersMount: boolean; /** * If `false`, hides the column headers. Affects the layout * of the table, so we need to know when this changes in order to * synchronize quadrant sizes properly. * * REQUIRES QUADRANT RESYNC * * @default true */ enableColumnHeader?: boolean; } // the debounce delay for updating the view on scroll. elements will be resized // and rejiggered once scroll has ceased for at least this long, but not before. const DEFAULT_VIEW_SYNC_DELAY = 500; // if there are no frozen rows or columns, we still want the quadrant to be 1px // bigger to reveal the header border. this border leaks into the cell grid to // ensure that selection overlay borders (e.g.) will be perfectly flush with it. const QUADRANT_MIN_SIZE = 1; // a list of props that trigger layout changes. when these props change, // quadrant views need to be explicitly resynchronized. const SYNC_TRIGGER_PROP_KEYS: Array = [ "enableRowHeader", "loadingOptions", "numFrozenColumns", "numFrozenRows", "numColumns", "numRows", "enableColumnInteractionBar", "didHeadersMount", "enableColumnHeader", ]; export class TableQuadrantStack extends AbstractComponent { // we want the user to explicitly pass a quadrantType. define defaultProps as a Partial to avoid // declaring that and other required props here. public static defaultProps: Partial = { enableColumnHeader: true, enableColumnInteractionBar: undefined, enableRowHeader: true, isHorizontalScrollDisabled: false, isVerticalScrollDisabled: false, throttleScrolling: true, viewSyncDelay: DEFAULT_VIEW_SYNC_DELAY, }; // Instance variables // ================== private quadrantRefs: Record = { [QuadrantType.MAIN]: {}, [QuadrantType.TOP]: {}, [QuadrantType.LEFT]: {}, [QuadrantType.TOP_LEFT]: {}, }; private quadrantRefHandlers = { [QuadrantType.MAIN]: this.generateQuadrantRefHandlers(QuadrantType.MAIN), [QuadrantType.TOP]: this.generateQuadrantRefHandlers(QuadrantType.TOP), [QuadrantType.LEFT]: this.generateQuadrantRefHandlers(QuadrantType.LEFT), [QuadrantType.TOP_LEFT]: this.generateQuadrantRefHandlers(QuadrantType.TOP_LEFT), }; // this flag helps us avoid redundant work in the MAIN quadrant's onScroll callback, if the // callback was triggered from a manual scrollTop/scrollLeft update within an onWheel. private wasMainQuadrantScrollTriggeredByWheelEvent = false; // keep throttled event callbacks around as instance variables, so we don't // have to continually reinstantiate them. private throttledHandleMainQuadrantScroll: (event: React.UIEvent) => any; private throttledHandleWheel: (event: React.WheelEvent) => any; // cancel function for the debounced view sync handler private cancelPendingViewSync?: () => void; private cache: TableQuadrantStackCache; // Public // ====== public constructor(props: TableQuadrantStackProps) { super(props); // callbacks trigger too frequently unless we throttle scroll and wheel // events. declare these functions on the component instance since // they're stateful. this.throttledHandleMainQuadrantScroll = CoreUtils.throttleReactEventCallback(this.handleMainQuadrantScroll); this.throttledHandleWheel = CoreUtils.throttleReactEventCallback(this.handleWheel); this.cache = new TableQuadrantStackCache(); } /** * Scroll the main quadrant to the specified scroll offset, keeping all other quadrants in sync. */ public scrollToPosition(scrollLeft: number, scrollTop: number) { const { scrollContainer } = this.quadrantRefs[QuadrantType.MAIN]; if (scrollContainer == null) { return; } this.wasMainQuadrantScrollTriggeredByWheelEvent = false; // this will trigger the main quadrant's scroll callback below scrollContainer.scrollLeft = scrollLeft; scrollContainer.scrollTop = scrollTop; this.syncQuadrantViews(); } /** * Synchronizes quadrant sizes and scroll offsets based on the current * column, row, and header sizes. Useful for correcting quadrant sizes after * explicitly resizing columns and rows, for instance. * * Invoking this method imperatively is cheaper than providing columnWidths * or rowHeights array props to TableQuadrantStack and forcing it to run * expensive array diffs upon every update. */ public synchronizeQuadrantViews() { this.syncQuadrantViews(); } public componentDidMount() { this.emitRefs(); this.syncQuadrantViews(); } public componentDidUpdate(prevProps: TableQuadrantStackProps) { if ( // sync'ing quadrant views triggers expensive reflows, so we only call // it when layout-affecting props change. !CoreUtils.shallowCompareKeys(this.props, prevProps, { include: SYNC_TRIGGER_PROP_KEYS, }) || // in addition to those props, we also care about frozen parts of the grid // which may cause the top / left quadrants to change height / width this.didFrozenColumnWidthsChange(prevProps) || this.didFrozenRowHeightsChange(prevProps) ) { this.emitRefs(); this.syncQuadrantViews(); } } public render() { const { grid, enableRowHeader, bodyRenderer, throttleScrolling, enableColumnHeader } = this.props; // use the more generic "scroll" event for the main quadrant to capture // *both* scrollbar interactions and trackpad/mousewheel gestures. const onMainQuadrantScroll = throttleScrolling ? this.throttledHandleMainQuadrantScroll : this.handleMainQuadrantScroll; const onWheel = throttleScrolling ? this.throttledHandleWheel : this.handleWheel; const baseProps = { bodyRenderer, enableColumnHeader, enableRowHeader, grid, onWheel, }; const shouldRenderLeftQuadrants = this.shouldRenderLeftQuadrants(); const maybeLeftQuadrant = shouldRenderLeftQuadrants ? ( ) : undefined; const maybeTopLeftQuadrant = shouldRenderLeftQuadrants ? ( ) : undefined; return (
{this.renderTableOverlay()} {maybeLeftQuadrant} {maybeTopLeftQuadrant}
); } // Ref handlers // ============ private generateQuadrantRefHandlers(quadrantType: QuadrantType): QuadrantRefHandlers { const reducer = (agg: QuadrantRefHandlers, key: keyof QuadrantRefHandlers) => { agg[key] = (ref: HTMLDivElement | null) => (this.quadrantRefs[quadrantType][key] = ref); return agg; }; const refHandlers: Array = [ "columnHeader", "menu", "quadrant", "rowHeader", "scrollContainer", ]; return refHandlers.reduce(reducer, {}); } // Scrolling overlay renderer // =========================== private renderTableOverlay = () => { const columnHeaderHeight = this.cache.getColumnHeaderHeight(); const mainScrollContainer = this.quadrantRefs[QuadrantType.MAIN].scrollContainer; const scrollBarWidth = ScrollUtils.measureScrollBarThickness(mainScrollContainer!, "vertical"); return this.props.renderScrollIndicatorOverlay?.(scrollBarWidth, columnHeaderHeight); }; // Quadrant-specific renderers // =========================== // Menu private renderMainQuadrantMenu = () => { return this.props.menuRenderer?.(this.quadrantRefHandlers[QuadrantType.MAIN].menu); }; private renderTopQuadrantMenu = () => { return this.props.menuRenderer?.(this.quadrantRefHandlers[QuadrantType.TOP].menu); }; private renderLeftQuadrantMenu = () => { return this.props.menuRenderer?.(this.quadrantRefHandlers[QuadrantType.LEFT].menu); }; private renderTopLeftQuadrantMenu = () => { return this.props.menuRenderer?.(this.quadrantRefHandlers[QuadrantType.TOP_LEFT].menu); }; // Column header private renderMainQuadrantColumnHeader = (showFrozenColumnsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.MAIN].columnHeader; const resizeHandler = this.handleColumnResizeGuideMain; const reorderingHandler = this.handleColumnsReordering; if (refHandler === undefined) { return undefined; } return this.props.columnHeaderRenderer?.(refHandler, resizeHandler, reorderingHandler, showFrozenColumnsOnly); }; private renderTopQuadrantColumnHeader = (showFrozenColumnsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.TOP].columnHeader; const resizeHandler = this.handleColumnResizeGuideTop; const reorderingHandler = this.handleColumnsReordering; if (refHandler === undefined) { return undefined; } return this.props.columnHeaderRenderer?.(refHandler, resizeHandler, reorderingHandler, showFrozenColumnsOnly); }; private renderLeftQuadrantColumnHeader = (showFrozenColumnsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.LEFT].columnHeader; const resizeHandler = this.handleColumnResizeGuideLeft; const reorderingHandler = this.handleColumnsReordering; if (refHandler === undefined) { return undefined; } return this.props.columnHeaderRenderer?.(refHandler, resizeHandler, reorderingHandler, showFrozenColumnsOnly); }; private renderTopLeftQuadrantColumnHeader = (showFrozenColumnsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.TOP_LEFT].columnHeader; const resizeHandler = this.handleColumnResizeGuideTopLeft; const reorderingHandler = this.handleColumnsReordering; if (refHandler === undefined) { return undefined; } return this.props.columnHeaderRenderer?.(refHandler, resizeHandler, reorderingHandler, showFrozenColumnsOnly); }; // Row header private renderMainQuadrantRowHeader = (showFrozenRowsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.MAIN].rowHeader; if (refHandler === undefined) { return undefined; } return this.props.rowHeaderRenderer?.( refHandler, this.handleRowResizeGuideMain, this.handleRowsReordering, showFrozenRowsOnly, ); }; private renderTopQuadrantRowHeader = (showFrozenRowsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.TOP].rowHeader; if (refHandler === undefined) { return undefined; } return this.props.rowHeaderRenderer?.( refHandler, this.handleRowResizeGuideTop, this.handleRowsReordering, showFrozenRowsOnly, ); }; private renderLeftQuadrantRowHeader = (showFrozenRowsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.LEFT].rowHeader; if (refHandler === undefined) { return undefined; } return this.props.rowHeaderRenderer?.( refHandler, this.handleRowResizeGuideLeft, this.handleRowsReordering, showFrozenRowsOnly, ); }; private renderTopLeftQuadrantRowHeader = (showFrozenRowsOnly: boolean) => { const refHandler = this.quadrantRefHandlers[QuadrantType.TOP_LEFT].rowHeader; if (refHandler === undefined) { return undefined; } return this.props.rowHeaderRenderer?.( refHandler, this.handleRowResizeGuideTopLeft, this.handleRowsReordering, showFrozenRowsOnly, ); }; // Event handlers // ============== // Scrolling // --------- private handleMainQuadrantScroll = (event: React.UIEvent) => { if (this.wasMainQuadrantScrollTriggeredByWheelEvent) { this.wasMainQuadrantScrollTriggeredByWheelEvent = false; return; } const mainScrollContainer = this.quadrantRefs[QuadrantType.MAIN].scrollContainer; if (mainScrollContainer == null) { return; } // invoke onScroll - which may read current scroll position - before // forcing a reflow with upcoming .scroll{Top,Left} setters. this.props.onScroll?.(event); // batch DOM reads here. note that onScroll events don't include deltas // like onWheel events do, so we have to read from the DOM directly. const nextScrollLeft = mainScrollContainer.scrollLeft; const nextScrollTop = mainScrollContainer.scrollTop; // with the "scroll" event, scroll offsets are updated prior to the // event's firing, so no explicit update needed. this.handleScrollOffsetChange("scrollLeft", nextScrollLeft); this.handleScrollOffsetChange("scrollTop", nextScrollTop); // sync less important view stuff when scrolling/wheeling stops. this.syncQuadrantViewsDebounced(); }; private handleWheel = (event: React.WheelEvent) => { // again, let the listener read the current scroll position before we // force a reflow by resizing or repositioning stuff. this.props.onScroll?.(event); // this helper performs DOM reads, so do them together before the writes below. const nextScrollLeft = this.getNextScrollOffset("horizontal", event.deltaX); const nextScrollTop = this.getNextScrollOffset("vertical", event.deltaY); // update this flag before updating the main quadrant scroll offsets, // since we need this set before onScroll fires. if (nextScrollLeft != null || nextScrollTop != null) { this.wasMainQuadrantScrollTriggeredByWheelEvent = true; } // manually update the affected quadrant's scroll position to make sure // it stays perfectly in sync with dependent quadrants in each frame. // note: these DOM writes are batched together after the reads above. if (nextScrollLeft !== undefined) { this.quadrantRefs[QuadrantType.MAIN].scrollContainer!.scrollLeft = nextScrollLeft; this.handleScrollOffsetChange("scrollLeft", nextScrollLeft); } if (nextScrollTop !== undefined) { this.quadrantRefs[QuadrantType.MAIN].scrollContainer!.scrollTop = nextScrollTop; this.handleScrollOffsetChange("scrollTop", nextScrollTop); } // sync less important view stuff when scrolling/wheeling stops. this.syncQuadrantViewsDebounced(); }; private getNextScrollOffset = (direction: "horizontal" | "vertical", delta: number) => { const { grid, isHorizontalScrollDisabled, isVerticalScrollDisabled } = this.props; const isHorizontal = direction === "horizontal"; const scrollKey = isHorizontal ? "scrollLeft" : "scrollTop"; const isScrollDisabled = isHorizontal ? isHorizontalScrollDisabled : isVerticalScrollDisabled; if (isScrollDisabled) { return undefined; } // measure client size on the first event of the current wheel gesture, // then grab cached values on successive events to eliminate DOM reads. // requires clearing the cached values in the debounced view-update at // the end of the wheel event. // ASSUMPTION: the client size won't change during the wheel event. let clientSize = isHorizontal ? this.cache.getScrollContainerClientWidth() : this.cache.getScrollContainerClientHeight(); if (clientSize == null) { // should trigger only on the first scroll of the wheel gesture. // will save client width and height sizes in the cache. clientSize = this.updateScrollContainerClientSize(isHorizontal); } // By now, the client width and height will have been saved in cache, so // they can't be undefined anymore. Also, events can only happen after // mount, so we're guaranteed to have measured the header sizes in // syncQuadrantViews() by now too, as it's invoked on mount. const containerSize = isHorizontal ? this.cache.getScrollContainerClientWidth()! - this.cache.getRowHeaderWidth() : this.cache.getScrollContainerClientHeight()! - this.cache.getColumnHeaderHeight(); const gridSize = isHorizontal ? grid.getWidth() : grid.getHeight(); const maxScrollOffset = Math.max(0, gridSize - containerSize); const currScrollOffset = this.cache.getScrollOffset(scrollKey); const nextScrollOffset = CoreUtils.clamp(currScrollOffset + delta, 0, maxScrollOffset); return nextScrollOffset; }; // Resizing // -------- // Columns private handleColumnResizeGuideMain = (verticalGuides: number[] | null) => { this.invokeColumnResizeHandler(verticalGuides, QuadrantType.MAIN); }; private handleColumnResizeGuideTop = (verticalGuides: number[] | null) => { this.invokeColumnResizeHandler(verticalGuides, QuadrantType.TOP); }; private handleColumnResizeGuideLeft = (verticalGuides: number[] | null) => { this.invokeColumnResizeHandler(verticalGuides, QuadrantType.LEFT); }; private handleColumnResizeGuideTopLeft = (verticalGuides: number[] | null) => { this.invokeColumnResizeHandler(verticalGuides, QuadrantType.TOP_LEFT); }; private invokeColumnResizeHandler = (verticalGuides: number[] | null, quadrantType: QuadrantType) => { const adjustedGuides = this.adjustVerticalGuides(verticalGuides, quadrantType); this.props.handleColumnResizeGuide?.(adjustedGuides); }; // Rows private handleRowResizeGuideMain = (horizontalGuides: number[] | null) => { this.invokeRowResizeHandler(horizontalGuides, QuadrantType.MAIN); }; private handleRowResizeGuideTop = (horizontalGuides: number[] | null) => { this.invokeRowResizeHandler(horizontalGuides, QuadrantType.TOP); }; private handleRowResizeGuideLeft = (horizontalGuides: number[] | null) => { this.invokeRowResizeHandler(horizontalGuides, QuadrantType.LEFT); }; private handleRowResizeGuideTopLeft = (horizontalGuides: number[] | null) => { this.invokeRowResizeHandler(horizontalGuides, QuadrantType.TOP_LEFT); }; private invokeRowResizeHandler = (horizontalGuides: number[] | null, quadrantType: QuadrantType) => { const adjustedGuides = this.adjustHorizontalGuides(horizontalGuides, quadrantType); this.props.handleRowResizeGuide?.(adjustedGuides); }; // Reordering // ---------- // Columns private handleColumnsReordering = (oldIndex: number, newIndex: number, length: number) => { const guideIndex = Utils.reorderedIndexToGuideIndex(oldIndex, newIndex, length); const leftOffset = this.props.grid.getCumulativeWidthBefore(guideIndex); const { numFrozenColumns = 0 } = this.props; const quadrantType = guideIndex <= numFrozenColumns ? QuadrantType.TOP_LEFT : QuadrantType.TOP; const verticalGuides = this.adjustVerticalGuides([leftOffset], quadrantType); this.props.handleColumnsReordering?.(verticalGuides); }; // Rows private handleRowsReordering = (oldIndex: number, newIndex: number, length: number) => { const guideIndex = Utils.reorderedIndexToGuideIndex(oldIndex, newIndex, length); const topOffset = this.props.grid.getCumulativeHeightBefore(guideIndex); const { numFrozenRows = 0 } = this.props; const quadrantType = guideIndex <= numFrozenRows ? QuadrantType.TOP_LEFT : QuadrantType.LEFT; const horizontalGuides = this.adjustHorizontalGuides([topOffset], quadrantType); this.props.handleRowsReordering?.(horizontalGuides); }; // Emitters // ======== private emitRefs() { setRef(this.props.quadrantRef, this.quadrantRefs[QuadrantType.MAIN].quadrant!); setRef(this.props.rowHeaderRef, this.quadrantRefs[QuadrantType.MAIN].rowHeader!); setRef(this.props.columnHeaderRef, this.quadrantRefs[QuadrantType.MAIN].columnHeader!); setRef(this.props.scrollContainerRef, this.quadrantRefs[QuadrantType.MAIN].scrollContainer!); } // Size syncing // ============ private syncQuadrantViewsDebounced = () => { const { viewSyncDelay } = this.props; if (viewSyncDelay! < 0) { // update synchronously this.syncQuadrantViews(); } else { // update asynchronously after a debounced delay this.cancelPendingViewSync?.(); this.cancelPendingViewSync = this.setTimeout(this.syncQuadrantViews, viewSyncDelay); } }; private syncQuadrantViews = () => { const mainRefs = this.quadrantRefs[QuadrantType.MAIN]; const mainScrollContainer = mainRefs.scrollContainer; // // Reads (batched to avoid DOM thrashing) // const rowHeaderWidth = this.measureDesiredRowHeaderWidth(); const columnHeaderHeight = this.measureDesiredColumnHeaderHeight(); const leftQuadrantGridWidth = this.getSecondaryQuadrantGridSize("width"); const topQuadrantGridHeight = this.getSecondaryQuadrantGridSize("height"); const leftQuadrantWidth = rowHeaderWidth + leftQuadrantGridWidth; const topQuadrantHeight = columnHeaderHeight + topQuadrantGridHeight; const rightScrollBarWidth = ScrollUtils.measureScrollBarThickness(mainScrollContainer!, "vertical"); const bottomScrollBarHeight = ScrollUtils.measureScrollBarThickness(mainScrollContainer!, "horizontal"); // if columnHeader is enabled, ensure neither of these measurements confusingly clamps to zero height. const adjustedColumnHeaderHeight = this.maybeIncreaseToMinColHeaderHeight(columnHeaderHeight); const adjustedTopQuadrantHeight = this.maybeIncreaseToMinColHeaderHeight(topQuadrantHeight); // Update cache: let's read now whatever values we might need later. // prevents unnecessary reflows in the future. this.cache.setRowHeaderWidth(rowHeaderWidth); this.cache.setColumnHeaderHeight(columnHeaderHeight); // ...however, we also clear the cached client size, so we can read it // again when a new scroll begins. not safe to assume this won't change. // TODO: maybe use the ResizeSensor? this.cache.setScrollContainerClientWidth(undefined); this.cache.setScrollContainerClientHeight(undefined); // // Writes (batched to avoid DOM thrashing) // // Quadrant-size sync'ing: make the quadrants precisely as big as they // need to be to fit their variable-sized headers and/or frozen areas. this.maybesSetQuadrantRowHeaderSizes(rowHeaderWidth); this.maybeSetQuadrantMenuElementSizes(rowHeaderWidth, adjustedColumnHeaderHeight); this.maybeSetQuadrantSizes(leftQuadrantWidth, adjustedTopQuadrantHeight); // Scrollbar clearance: tweak the quadrant bottom/right offsets to // reveal the MAIN-quadrant scrollbars if they're visible. this.maybeSetQuadrantPositionOffset(QuadrantType.TOP, "right", rightScrollBarWidth); this.maybeSetQuadrantPositionOffset(QuadrantType.LEFT, "bottom", bottomScrollBarHeight); // Scroll syncing: sync the scroll offsets of quadrants that may or may // not have been around prior to this update. this.maybeSetQuadrantScrollOffset(QuadrantType.LEFT, "scrollTop"); this.maybeSetQuadrantScrollOffset(QuadrantType.TOP, "scrollLeft"); }; private maybeSetQuadrantSizes = (width: number, height: number) => { const leftWidth = Utils.clamp(width, this.props.enableRowHeader ? Grid.MIN_ROW_HEADER_WIDTH : 0); const topHeight = Utils.clamp(height, this.props.enableColumnHeader ? Grid.MIN_COLUMN_HEADER_HEIGHT : 0); this.maybesSetQuadrantSize(QuadrantType.LEFT, "width", leftWidth); this.maybesSetQuadrantSize(QuadrantType.TOP, "height", topHeight); this.maybesSetQuadrantSize(QuadrantType.TOP_LEFT, "width", leftWidth); this.maybesSetQuadrantSize(QuadrantType.TOP_LEFT, "height", topHeight); }; private maybesSetQuadrantSize = (quadrantType: QuadrantType, dimension: "width" | "height", value: number) => { const { quadrant } = this.quadrantRefs[quadrantType]; if (quadrant != null) { quadrant.style[dimension] = `${value}px`; } }; private maybeSetQuadrantPositionOffset = (quadrantType: QuadrantType, side: "right" | "bottom", value: number) => { const { quadrant } = this.quadrantRefs[quadrantType]; if (quadrant != null) { quadrant.style[side] = `${value}px`; } }; private maybesSetQuadrantRowHeaderSizes = (width: number) => { const rowHeaderWidth = Utils.clamp(width, this.props.enableRowHeader ? Grid.MIN_ROW_HEADER_WIDTH : 0); this.maybeSetQuadrantRowHeaderSize(QuadrantType.MAIN, rowHeaderWidth); this.maybeSetQuadrantRowHeaderSize(QuadrantType.TOP, rowHeaderWidth); this.maybeSetQuadrantRowHeaderSize(QuadrantType.LEFT, rowHeaderWidth); this.maybeSetQuadrantRowHeaderSize(QuadrantType.TOP_LEFT, rowHeaderWidth); }; private maybeSetQuadrantRowHeaderSize = (quadrantType: QuadrantType, width: number) => { const { rowHeader } = this.quadrantRefs[quadrantType]; if (rowHeader != null) { rowHeader.style.width = `${width}px`; } }; private maybeSetQuadrantMenuElementSizes = (width: number, height: number) => { const rowHeaderWidth = Utils.clamp(width, this.props.enableRowHeader ? Grid.MIN_ROW_HEADER_WIDTH : 0); this.maybeSetQuadrantMenuElementSize(QuadrantType.MAIN, rowHeaderWidth, height); this.maybeSetQuadrantMenuElementSize(QuadrantType.TOP, rowHeaderWidth, height); this.maybeSetQuadrantMenuElementSize(QuadrantType.LEFT, rowHeaderWidth, height); this.maybeSetQuadrantMenuElementSize(QuadrantType.TOP_LEFT, rowHeaderWidth, height); }; private maybeSetQuadrantMenuElementSize = (quadrantType: QuadrantType, width: number, height: number) => { const { menu } = this.quadrantRefs[quadrantType]; if (menu != null) { menu.style.width = `${width}px`; menu.style.height = `${height}px`; } }; private maybeSetQuadrantScrollOffset = ( quadrantType: QuadrantType, scrollKey: "scrollLeft" | "scrollTop", newOffset?: number, ) => { const { scrollContainer } = this.quadrantRefs[quadrantType]; const scrollOffset = newOffset != null ? newOffset : this.cache.getScrollOffset(scrollKey); if (scrollContainer != null) { scrollContainer[scrollKey] = scrollOffset; } }; private handleScrollOffsetChange = (scrollKey: "scrollLeft" | "scrollTop", offset: number) => { this.cache.setScrollOffset(scrollKey, offset); const dependentQuadrantType = scrollKey === "scrollLeft" ? QuadrantType.TOP : QuadrantType.LEFT; this.maybeSetQuadrantScrollOffset(dependentQuadrantType, scrollKey); }; // this function is named 'update' instead of 'set', because a 'set' // function typically takes the new value as a parameter. we avoid that to // keep the isHorizontal logic tree contained within this function. private updateScrollContainerClientSize(isHorizontal: boolean) { const mainScrollContainer = this.quadrantRefs[QuadrantType.MAIN].scrollContainer; if (isHorizontal) { this.cache.setScrollContainerClientWidth(mainScrollContainer?.clientWidth); return this.cache.getScrollContainerClientWidth(); } else { this.cache.setScrollContainerClientHeight(mainScrollContainer?.clientHeight); return this.cache.getScrollContainerClientHeight(); } } private maybeIncreaseToMinColHeaderHeight(height: number) { if (this.props.enableColumnHeader) { return height <= QUADRANT_MIN_SIZE ? Grid.MIN_COLUMN_HEADER_HEIGHT : height; } else { return height; } } // Helpers // ======= /** Returns true the cumulative width of all frozen columns in the grid changed. */ private didFrozenColumnWidthsChange(prevProps: TableQuadrantStackProps) { return ( this.props.numFrozenColumns > 0 && this.props.grid !== prevProps.grid && this.props.grid.getCumulativeWidthAt(this.props.numFrozenColumns - 1) !== prevProps.grid.getCumulativeWidthAt(prevProps.numFrozenColumns - 1) ); } /** Returns true the cumulative height of all frozen rows in the grid changed. */ private didFrozenRowHeightsChange(prevProps: TableQuadrantStackProps) { return ( this.props.numFrozenRows > 0 && this.props.grid !== prevProps.grid && this.props.grid.getCumulativeHeightAt(this.props.numFrozenRows - 1) !== prevProps.grid.getCumulativeHeightAt(prevProps.numFrozenRows - 1) ); } /** * Returns the width or height of *only the grid* in the secondary quadrants * (TOP, LEFT, TOP_LEFT), based on the number of frozen rows and columns. */ private getSecondaryQuadrantGridSize(dimension: "width" | "height") { const { grid, numFrozenColumns, numFrozenRows } = this.props; const numFrozen = (dimension === "width" ? numFrozenColumns : numFrozenRows) ?? 0; const getterFn = dimension === "width" ? grid.getCumulativeWidthAt : grid.getCumulativeHeightAt; // both getter functions do O(1) lookups. return numFrozen > 0 ? getterFn(numFrozen - 1) : QUADRANT_MIN_SIZE; } /** * Measures the desired width of the row header based on its tallest * contents. */ private measureDesiredRowHeaderWidth() { // the MAIN row header serves as the source of truth const mainRowHeader = this.quadrantRefs[QuadrantType.MAIN].rowHeader; if (mainRowHeader == null) { return 0; } else { // (alas, we must force a reflow to measure the row header's "desired" width) mainRowHeader.style.width = "auto"; return Utils.clamp(mainRowHeader.clientWidth, Grid.MIN_ROW_HEADER_WIDTH); } } /** * Measures the desired height of the column header based on its tallest * contents. */ private measureDesiredColumnHeaderHeight() { // unlike the row headers, the column headers are in a display-flex // layout and are not actually bound by any fixed `height` that we set, // so they'll grow freely to their necessary size. makes measuring easy! const mainColumnHeader = this.quadrantRefs[QuadrantType.MAIN].columnHeader; return mainColumnHeader == null ? 0 : Utils.clamp(mainColumnHeader.clientHeight, Grid.MIN_COLUMN_HEADER_HEIGHT); } private shouldRenderLeftQuadrants(props: TableQuadrantStackProps = this.props) { const { enableRowHeader, numFrozenColumns } = props; return enableRowHeader || (numFrozenColumns != null && numFrozenColumns > 0); } // Resizing // should return empty array [] if we just finished resizing private adjustVerticalGuides(verticalGuides: number[] | null, quadrantType: QuadrantType) { const isFrozenQuadrant = quadrantType === QuadrantType.LEFT || quadrantType === QuadrantType.TOP_LEFT; const scrollAmount = isFrozenQuadrant ? 0 : this.cache.getScrollOffset("scrollLeft"); const rowHeaderWidth = this.cache.getRowHeaderWidth(); return verticalGuides?.map(verticalGuide => verticalGuide - scrollAmount + rowHeaderWidth) ?? []; } // should return empty array [] if we just finished resizing private adjustHorizontalGuides(horizontalGuides: number[] | null, quadrantType: QuadrantType) { const isFrozenQuadrant = quadrantType === QuadrantType.TOP || quadrantType === QuadrantType.TOP_LEFT; const scrollAmount = isFrozenQuadrant ? 0 : this.cache.getScrollOffset("scrollTop"); const columnHeaderHeight = this.cache.getColumnHeaderHeight(); return horizontalGuides?.map(horizontalGuide => horizontalGuide - scrollAmount + columnHeaderHeight) ?? []; } }