From 35311c832040b29e362c28f80983b4664c9aa1d5 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Tue, 7 Nov 2023 16:09:40 -0500 Subject: [PATCH] feat: Add ResizeObserver to Grid and Chart (#1626) - ResizeObserver is now widely available in all browsers, so use it to listen for resizing of our grid and plot elements - Pull `grid-wrapper` from `IrisGrid` and put it directly in `Grid` - Now `Grid` doesn't have to listen to the "parent" element, which was kind of strange in the first place. - Tested by opening up some tables and charts, resizing the panels and ensuring they updated correctly. --- packages/chart/src/Chart.tsx | 15 ++++++ .../src/panels/ChartPanel.tsx | 17 ------ .../src/panels/IrisGridPanel.tsx | 6 --- packages/grid/src/Grid.scss | 12 +++++ packages/grid/src/Grid.test.tsx | 11 ++++ packages/grid/src/Grid.tsx | 36 ++++++++++--- packages/iris-grid/src/IrisGrid.scss | 7 --- packages/iris-grid/src/IrisGrid.test.tsx | 13 ++++- packages/iris-grid/src/IrisGrid.tsx | 53 +++++++++---------- packages/react-hooks/src/index.ts | 1 + packages/react-hooks/src/useResizeObserver.ts | 28 ++++++++++ 11 files changed, 130 insertions(+), 69 deletions(-) create mode 100644 packages/react-hooks/src/useResizeObserver.ts diff --git a/packages/chart/src/Chart.tsx b/packages/chart/src/Chart.tsx index 67220688d0..4544331ea8 100644 --- a/packages/chart/src/Chart.tsx +++ b/packages/chart/src/Chart.tsx @@ -132,6 +132,7 @@ export class Chart extends Component { this.handleModelEvent = this.handleModelEvent.bind(this); this.handlePlotUpdate = this.handlePlotUpdate.bind(this); this.handleRelayout = this.handleRelayout.bind(this); + this.handleResize = this.handleResize.bind(this); this.handleRestyle = this.handleRestyle.bind(this); this.PlotComponent = createPlotlyComponent(props.Plotly); @@ -144,6 +145,7 @@ export class Chart extends Component { this.isSubscribed = false; this.isLoadedFired = false; this.currentSeries = 0; + this.resizeObserver = new window.ResizeObserver(this.handleResize); this.state = { data: null, @@ -170,6 +172,9 @@ export class Chart extends Component { if (isActive) { this.subscribe(model); } + if (this.plotWrapper.current != null) { + this.resizeObserver.observe(this.plotWrapper.current); + } } componentDidUpdate(prevProps: ChartProps): void { @@ -183,6 +188,7 @@ export class Chart extends Component { if (isActive !== prevProps.isActive) { if (isActive) { + this.updateDimensions(); this.subscribe(model); } else { this.unsubscribe(model); @@ -193,6 +199,8 @@ export class Chart extends Component { componentWillUnmount(): void { const { model } = this.props; this.unsubscribe(model); + + this.resizeObserver.disconnect(); } currentSeries: number; @@ -219,6 +227,9 @@ export class Chart extends Component { isLoadedFired: boolean; + // Listen for resizing of the element and update the canvas appropriately + resizeObserver: ResizeObserver; + getCachedConfig = memoize( ( downsamplingError: unknown, @@ -468,6 +479,10 @@ export class Chart extends Component { this.updateModelDimensions(); } + handleResize(): void { + this.updateDimensions(); + } + handleRestyle([changes, seriesIndexes]: readonly [ Record, number[], diff --git a/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx b/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx index 8b6793d3a3..5ac464e1d6 100644 --- a/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx @@ -218,7 +218,6 @@ export class ChartPanel extends Component { this.handleError = this.handleError.bind(this); this.handleLoadError = this.handleLoadError.bind(this); this.handleLoadSuccess = this.handleLoadSuccess.bind(this); - this.handleResize = this.handleResize.bind(this); this.handleSettingsChanged = this.handleSettingsChanged.bind(this); this.handleOpenLinker = this.handleOpenLinker.bind(this); this.handleShow = this.handleShow.bind(this); @@ -235,7 +234,6 @@ export class ChartPanel extends Component { this.handleClearAllFilters = this.handleClearAllFilters.bind(this); this.panelContainer = props.containerRef ?? React.createRef(); - this.chart = React.createRef(); this.pending = new Pending(); const { metadata, panelState } = props; @@ -337,8 +335,6 @@ export class ChartPanel extends Component { panelContainer: RefObject; - chart: RefObject; - pending: Pending; initModel(): void { @@ -683,10 +679,6 @@ export class ChartPanel extends Component { this.setState({ isLoading: false }); } - handleResize(): void { - this.updateChart(); - } - handleSettingsChanged(update: Partial): void { this.setState(({ settings: prevSettings }) => { const settings = { @@ -752,7 +744,6 @@ export class ChartPanel extends Component { this.setState({ isActive }, () => { if (isActive) { this.loadModelIfNecessary(); - this.updateChart(); } }); } @@ -1026,12 +1017,6 @@ export class ChartPanel extends Component { }); } - updateChart(): void { - if (this.chart.current) { - this.chart.current.updateDimensions(); - } - } - render(): ReactElement { const { columnSelectionValidator, @@ -1097,7 +1082,6 @@ export class ChartPanel extends Component { glEventHub={glEventHub} onHide={this.handleHide} onClearAllFilters={this.handleClearAllFilters} - onResize={this.handleResize} onShow={this.handleShow} onTabBlur={this.handleTabBlur} onTabFocus={this.handleTabFocus} @@ -1118,7 +1102,6 @@ export class ChartPanel extends Component { isActive={isActive} model={model} settings={settings} - ref={this.chart} onDisconnect={this.handleDisconnect} onReconnect={this.handleReconnect} onUpdate={this.handleUpdate} diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index 117be35cbc..fbb85c2821 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -251,7 +251,6 @@ export class IrisGridPanel extends PureComponent< this.handleGridStateChange = this.handleGridStateChange.bind(this); this.handlePluginStateChange = this.handlePluginStateChange.bind(this); this.handleCreateChart = this.handleCreateChart.bind(this); - this.handleResize = this.handleResize.bind(this); this.handleShow = this.handleShow.bind(this); this.handleTabClicked = this.handleTabClicked.bind(this); this.handleDisconnect = this.handleDisconnect.bind(this); @@ -762,10 +761,6 @@ export class IrisGridPanel extends PureComponent< glEventHub.emit(IrisGridEvent.DATA_SELECTED, this, dataMap); } - handleResize(): void { - this.updateGrid(); - } - handleShow(): void { this.updateGrid(); } @@ -1277,7 +1272,6 @@ export class IrisGridPanel extends PureComponent< glContainer={glContainer} glEventHub={glEventHub} onClearAllFilters={this.handleClearAllFilters} - onResize={this.handleResize} onShow={this.handleShow} onTabFocus={this.handleShow} onTabClicked={this.handleTabClicked} diff --git a/packages/grid/src/Grid.scss b/packages/grid/src/Grid.scss index fb160233b7..3af42458cb 100644 --- a/packages/grid/src/Grid.scss +++ b/packages/grid/src/Grid.scss @@ -1,3 +1,15 @@ +.grid-wrapper { + flex: 1 1 0; + max-width: 100%; + max-height: 100%; + // min-width/height used to make sure grid shrinks properly when notification bars are added/resized + min-width: 0; + min-height: 0; + position: relative; + font: sans-serif; + font-feature-settings: 'tnum'; +} + .grid-canvas { display: block; } diff --git a/packages/grid/src/Grid.test.tsx b/packages/grid/src/Grid.test.tsx index a7806934f5..e85dc2cd66 100644 --- a/packages/grid/src/Grid.test.tsx +++ b/packages/grid/src/Grid.test.tsx @@ -70,10 +70,21 @@ function makeMockCanvas() { }; } +function makeMockWrapper() { + return { + focus: jest.fn(), + getBoundingClientRect: () => ({ width: VIEW_SIZE, height: VIEW_SIZE }), + }; +} + function createNodeMock(element: ReactElement) { if (element.type === 'canvas') { return makeMockCanvas(); } + if (element?.props?.className?.includes('grid-wrapper') === true) { + return makeMockWrapper(); + } + return null; } diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index ca23bd7ec6..8aa4a28a09 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -1,5 +1,10 @@ /* eslint react/no-did-update-set-state: "off" */ -import React, { CSSProperties, PureComponent, ReactNode } from 'react'; +import React, { + CSSProperties, + PureComponent, + ReactNode, + RefObject, +} from 'react'; import classNames from 'classnames'; import memoize from 'memoize-one'; import clamp from 'lodash.clamp'; @@ -69,6 +74,9 @@ type LegacyCanvasRenderingContext2D = CanvasRenderingContext2D & { }; export type GridProps = typeof Grid.defaultProps & { + // Children to render in the grid + children?: ReactNode; + // Options to set on the canvas canvasOptions?: CanvasRenderingContext2DSettings; @@ -295,6 +303,12 @@ class Grid extends PureComponent { canvasContext: CanvasRenderingContext2D | null; + // The wrapper element for the canvas, used for sizing + canvasWrapper: RefObject; + + // Listen for resizing of the element and update the canvas appropriately + resizeObserver: ResizeObserver; + // We draw the canvas on the next animation frame, keep track of the next one animationFrame: number | null; @@ -351,6 +365,8 @@ class Grid extends PureComponent { this.canvas = null; this.canvasContext = null; + this.canvasWrapper = React.createRef(); + this.resizeObserver = new window.ResizeObserver(this.handleResize); this.animationFrame = null; this.prevMetrics = null; @@ -457,7 +473,9 @@ class Grid extends PureComponent { this.canvas?.addEventListener('wheel', this.handleWheel, { passive: false, }); - window.addEventListener('resize', this.handleResize); + if (this.canvasWrapper.current != null) { + this.resizeObserver.observe(this.canvasWrapper.current); + } this.updateCanvas(); @@ -561,7 +579,7 @@ class Grid extends PureComponent { this.handleMouseUp as unknown as EventListenerOrEventListenerObject, true ); - window.removeEventListener('resize', this.handleResize); + this.resizeObserver.disconnect(); this.stopDragTimer(); } @@ -801,17 +819,17 @@ class Grid extends PureComponent { } private updateCanvasScale(): void { - const { canvas, canvasContext } = this; + const { canvas, canvasContext, canvasWrapper } = this; if (!canvas) throw new Error('canvas not set'); if (!canvasContext) throw new Error('canvasContext not set'); - if (!canvas.parentElement) throw new Error('Canvas has no parent element'); + if (!canvasWrapper.current) throw new Error('canvasWrapper not set'); const scale = Grid.getScale(canvasContext); // the parent wrapper has 100% width/height, and is used for determining size // we don't want to stretch the canvas to 100%, to avoid fractional pixels. // A wrapper element must be used for sizing, and canvas size must be // set manually to a floored value in css and a scaled value in width/height - const rect = canvas.parentElement.getBoundingClientRect(); + const rect = canvasWrapper.current.getBoundingClientRect(); const width = Math.floor(rect.width); const height = Math.floor(rect.height); canvas.style.width = `${width}px`; @@ -2177,10 +2195,11 @@ class Grid extends PureComponent { } render(): ReactNode { + const { children } = this.props; const { cursor } = this.state; return ( - <> +
{ @@ -2198,7 +2217,8 @@ class Grid extends PureComponent { Your browser does not support HTML canvas. Update your browser? {this.renderInputField()} - + {children} +
); } } diff --git a/packages/iris-grid/src/IrisGrid.scss b/packages/iris-grid/src/IrisGrid.scss index 629504b418..5a3a156cf1 100644 --- a/packages/iris-grid/src/IrisGrid.scss +++ b/packages/iris-grid/src/IrisGrid.scss @@ -79,13 +79,6 @@ $cell-invalid-box-shadow: } .grid-wrapper { - flex: 1 1 0; - max-width: 100%; - max-height: 100%; - // min-width/height used to make sure grid shrinks properly when notification bars are added/resized - min-width: 0; - min-height: 0; - position: relative; font: $iris-grid-font; font-feature-settings: $iris-grid-font-feature-settings; transition: all $transition-mid; diff --git a/packages/iris-grid/src/IrisGrid.test.tsx b/packages/iris-grid/src/IrisGrid.test.tsx index 352d9afa86..d255416fe8 100644 --- a/packages/iris-grid/src/IrisGrid.test.tsx +++ b/packages/iris-grid/src/IrisGrid.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactElement } from 'react'; import TestRenderer from 'react-test-renderer'; import dh from '@deephaven/jsapi-shim'; import { DateUtils, Settings } from '@deephaven/jsapi-utils'; @@ -48,10 +48,19 @@ function makeMockCanvas() { }; } -function createNodeMock(element) { +function makeMockWrapper() { + return { + getBoundingClientRect: () => ({ width: VIEW_SIZE, height: VIEW_SIZE }), + }; +} + +function createNodeMock(element: ReactElement) { if (element.type === 'canvas') { return makeMockCanvas(); } + if (element?.props?.className?.includes('grid-wrapper') === true) { + return makeMockWrapper(); + } return element; } diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 4f8163a178..664acd405d 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -597,7 +597,6 @@ export class IrisGrid extends Component { this.handleGotoValueSubmitted = this.handleGotoValueSubmitted.bind(this); this.grid = null; - this.gridWrapper = null; this.lastLoadedConfig = null; this.pending = new Pending(); this.globalColumnFormats = EMPTY_ARRAY; @@ -945,8 +944,6 @@ export class IrisGrid extends Component { grid: Grid | null; - gridWrapper: HTMLDivElement | null; - lastFocusedFilterBarColumn?: number; lastLoadedConfig: Pick< @@ -1011,6 +1008,10 @@ export class IrisGrid extends Component { tableUtils: TableUtils; + get gridWrapper(): HTMLDivElement | null { + return this.grid?.canvasWrapper.current ?? null; + } + getAdvancedMenuOpenedHandler = memoize( (column: ModelIndex) => this.handleAdvancedMenuOpened.bind(this, column), { max: 100 } @@ -4485,33 +4486,27 @@ export class IrisGrid extends Component { /> -
{ - this.gridWrapper = gridWrapper; + { + this.grid = grid; }} + isStickyBottom={!isEditableGridModel(model) || !model.isEditable} + isStuckToBottom={isStuckToBottom} + isStuckToRight={isStuckToRight} + metricCalculator={metricCalculator} + model={model} + keyHandlers={keyHandlers} + mouseHandlers={mouseHandlers} + movedColumns={movedColumns} + movedRows={movedRows} + onError={this.handleGridError} + onViewChanged={this.handleViewChanged} + onSelectionChanged={this.handleSelectionChanged} + onMovedColumnsChanged={this.handleMovedColumnsChanged} + renderer={this.renderer} + stateOverride={stateOverride} + theme={theme} > - { - this.grid = grid; - }} - isStickyBottom={!isEditableGridModel(model) || !model.isEditable} - isStuckToBottom={isStuckToBottom} - isStuckToRight={isStuckToRight} - metricCalculator={metricCalculator} - model={model} - keyHandlers={keyHandlers} - mouseHandlers={mouseHandlers} - movedColumns={movedColumns} - movedRows={movedRows} - onError={this.handleGridError} - onViewChanged={this.handleViewChanged} - onSelectionChanged={this.handleSelectionChanged} - onMovedColumnsChanged={this.handleMovedColumnsChanged} - renderer={this.renderer} - stateOverride={stateOverride} - theme={theme} - /> { this.getExpandCellTooltip(expandCellTooltipProps)} {linkHoverTooltipProps && this.getLinkHoverTooltip(linkHoverTooltipProps)} -
+ { + if (element == null) { + return; + } + + const resizeObserverInstance = new window.ResizeObserver(onResize); + resizeObserverInstance.observe(element); + + return () => { + if (element != null) { + resizeObserverInstance.unobserve(element); + } + }; + }, [element, onResize]); +} + +export default useResizeObserver;