diff --git a/packages/grafana-ui/src/components/Splitter/useSplitter.ts b/packages/grafana-ui/src/components/Splitter/useSplitter.ts index dfb293ebbf7..4cd43bf6bbc 100644 --- a/packages/grafana-ui/src/components/Splitter/useSplitter.ts +++ b/packages/grafana-ui/src/components/Splitter/useSplitter.ts @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { clamp, throttle } from 'lodash'; -import { useCallback, useId, useLayoutEffect, useRef } from 'react'; +import { clamp } from 'lodash'; +import { useCallback, useId, useRef } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -12,16 +12,18 @@ import { DragHandlePosition, getDragStyles } from '../DragHandle/DragHandle'; export interface UseSplitterOptions { /** * The initial size of the primary pane between 0-1, defaults to 0.5 + * If `usePixels` is true, this is the initial size in pixels of the second pane. */ initialSize?: number; direction: 'row' | 'column'; dragPosition?: DragHandlePosition; + usePixels?: boolean; /** * Called when ever the size of the primary pane changes * @param flexSize (float from 0-1) */ - onSizeChanged?: (flexSize: number, pixelSize: number) => void; - onResizing?: (flexSize: number, pixelSize: number) => void; + onSizeChanged?: (flexSize: number, firstPanePixels: number, secondPanePixels: number) => void; + onResizing?: (flexSize: number, firstPanePixels: number, secondPanePixels: number) => void; // Size of the region left of the handle indicator that is responsive to dragging. At the same time acts as a margin // pushing the left pane content left. @@ -48,7 +50,14 @@ const propsForDirection = { } as const; export function useSplitter(options: UseSplitterOptions) { - const { direction, initialSize = 0.5, dragPosition = 'middle', onResizing, onSizeChanged } = options; + const { + direction, + initialSize = options.usePixels ? 300 : 0.5, + dragPosition = 'middle', + onResizing, + onSizeChanged, + usePixels, + } = options; const handleSize = getPixelSize(options.handleSize); const splitterRef = useRef(null); @@ -56,43 +65,19 @@ export function useSplitter(options: UseSplitterOptions) { const secondPaneRef = useRef(null); const containerRef = useRef(null); const containerSize = useRef(null); - const primarySizeRef = useRef<'1fr' | number>('1fr'); - const firstPaneMeasurements = useRef(undefined); + const primarySizeRef = useRef(null); + const referencePaneSize = useRef(undefined); const savedPos = useRef(undefined); const measurementProp = propsForDirection[direction].dim; const clientAxis = propsForDirection[direction].axis; const minDimProp = propsForDirection[direction].min; const maxDimProp = propsForDirection[direction].max; - - // Using a resize observer here, as with content or screen based width/height the ratio between panes might - // change after a window resize, so ariaValueNow needs to be updated accordingly - useResizeObserver( - containerRef.current!, - (entries) => { - for (const entry of entries) { - if (!entry.target.isSameNode(containerRef.current)) { - return; - } - - if (!firstPaneRef.current) { - return; - } - - const curSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; - const newDims = measureElement(firstPaneRef.current); - - splitterRef.current!.ariaValueNow = ariaValue(curSize, newDims[minDimProp], newDims[maxDimProp]); - } - }, - 500, - [maxDimProp, minDimProp, direction, measurementProp] - ); - const dragStart = useRef(null); + const onPointerDown = useCallback( (e: React.PointerEvent) => { - if (!firstPaneRef.current) { + if (!firstPaneRef.current || !secondPaneRef.current) { return; } @@ -100,32 +85,54 @@ export function useSplitter(options: UseSplitterOptions) { primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; - // set position at start of drag dragStart.current = e[clientAxis]; splitterRef.current!.setPointerCapture(e.pointerId); - firstPaneMeasurements.current = measureElement(firstPaneRef.current); + + if (usePixels) { + referencePaneSize.current = measureElement(secondPaneRef.current, usePixels); + } else { + referencePaneSize.current = measureElement(firstPaneRef.current); + } savedPos.current = undefined; }, - [measurementProp, clientAxis] + [measurementProp, clientAxis, usePixels] + ); + + const onUpdateSize = useCallback( + (diff: number) => { + if (!containerSize.current || !primarySizeRef.current || !secondPaneRef.current) { + return; + } + + const firstPanePixels = primarySizeRef.current; + const secondPanePixels = containerSize.current - firstPanePixels - handleSize; + const dims = referencePaneSize.current!; + + if (usePixels) { + const newSize = clamp(secondPanePixels - diff, dims[minDimProp], dims[maxDimProp]); + secondPaneRef.current!.style.flexBasis = `${newSize}px`; + splitterRef.current!.ariaValueNow = `${newSize}`; + onResizing?.(newSize, firstPanePixels + diff, newSize); + } else { + const newSize = clamp(primarySizeRef.current + diff, dims[minDimProp], dims[maxDimProp]); + const newFlex = newSize / (containerSize.current! - handleSize); + firstPaneRef.current!.style.flexGrow = `${newFlex}`; + secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; + splitterRef.current!.ariaValueNow = ariaValue(newSize, dims[minDimProp], dims[maxDimProp]); + onResizing?.(newFlex, newSize, secondPanePixels - diff); + } + }, + [onResizing, handleSize, usePixels, minDimProp, maxDimProp] ); const onPointerMove = useCallback( (e: React.PointerEvent) => { - if (dragStart.current !== null && primarySizeRef.current !== '1fr') { - const diff = e[clientAxis] - dragStart.current; - const dims = firstPaneMeasurements.current!; - const newSize = clamp(primarySizeRef.current + diff, dims[minDimProp], dims[maxDimProp]); - const newFlex = newSize / (containerSize.current! - handleSize); - - firstPaneRef.current!.style.flexGrow = `${newFlex}`; - secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; - splitterRef.current!.ariaValueNow = ariaValue(newSize, dims[minDimProp], dims[maxDimProp]); - - onResizing?.(newFlex, newSize); + if (dragStart.current !== null) { + onUpdateSize(e[clientAxis] - dragStart.current); } }, - [handleSize, clientAxis, minDimProp, maxDimProp, onResizing] + [onUpdateSize, clientAxis] ); const onPointerUp = useCallback( @@ -133,14 +140,16 @@ export function useSplitter(options: UseSplitterOptions) { e.preventDefault(); e.stopPropagation(); - splitterRef.current!.releasePointerCapture(e.pointerId); dragStart.current = null; - if (typeof primarySizeRef.current === 'number') { - onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current); - } + splitterRef.current!.releasePointerCapture(e.pointerId); + + const firstPaneSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + const secondPanePixels = containerSize.current! - firstPaneSize - handleSize; + + onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), firstPaneSize, secondPanePixels); }, - [onSizeChanged] + [onSizeChanged, handleSize, measurementProp] ); const pressedKeys = useRef(new Set()); @@ -151,7 +160,7 @@ export function useSplitter(options: UseSplitterOptions) { if (nothingPressed) { keysLastHandledAt.current = null; return; - } else if (primarySizeRef.current === '1fr') { + } else if (primarySizeRef.current === null) { return; } @@ -175,21 +184,17 @@ export function useSplitter(options: UseSplitterOptions) { } } - const firstPaneDims = firstPaneMeasurements.current!; - const curSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; - const newSize = clamp(curSize + sizeChange, firstPaneDims[minDimProp], firstPaneDims[maxDimProp]); - const newFlex = newSize / (containerSize.current! - handleSize); + // measure primary and container + primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; - firstPaneRef.current!.style.flexGrow = `${newFlex}`; - secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; - splitterRef.current!.ariaValueNow = ariaValue(newSize, firstPaneDims[minDimProp], firstPaneDims[maxDimProp]); - - onResizing?.(newFlex, newSize); + onUpdateSize(sizeChange); keysLastHandledAt.current = time; + window.requestAnimationFrame(handlePressedKeys); }, - [direction, handleSize, minDimProp, maxDimProp, measurementProp, onResizing] + [direction, measurementProp, onUpdateSize] ); const onKeyDown = useCallback( @@ -198,35 +203,6 @@ export function useSplitter(options: UseSplitterOptions) { return; } - if (e.key === 'Enter') { - if (savedPos.current === undefined) { - savedPos.current = firstPaneRef.current!.style.flexGrow; - firstPaneRef.current!.style.flexGrow = '0'; - secondPaneRef.current!.style.flexGrow = '1'; - } else { - firstPaneRef.current!.style.flexGrow = savedPos.current; - secondPaneRef.current!.style.flexGrow = `${1 - parseFloat(savedPos.current)}`; - savedPos.current = undefined; - } - return; - } else if (e.key === 'Home') { - firstPaneMeasurements.current = measureElement(firstPaneRef.current); - containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; - const newFlex = firstPaneMeasurements.current[minDimProp] / (containerSize.current - handleSize); - firstPaneRef.current.style.flexGrow = `${newFlex}`; - secondPaneRef.current.style.flexGrow = `${1 - newFlex}`; - splitterRef.current.ariaValueNow = '0'; - return; - } else if (e.key === 'End') { - firstPaneMeasurements.current = measureElement(firstPaneRef.current); - containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; - const newFlex = firstPaneMeasurements.current[maxDimProp] / (containerSize.current - handleSize); - firstPaneRef.current!.style.flexGrow = `${newFlex}`; - secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`; - splitterRef.current!.ariaValueNow = '100'; - return; - } - if ( !( (direction === 'column' && VERTICAL_KEYS.has(e.key)) || @@ -240,9 +216,16 @@ export function useSplitter(options: UseSplitterOptions) { savedPos.current = undefined; e.preventDefault(); e.stopPropagation(); + primarySizeRef.current = firstPaneRef.current.getBoundingClientRect()[measurementProp]; containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp]; - firstPaneMeasurements.current = measureElement(firstPaneRef.current); + + if (usePixels) { + referencePaneSize.current = measureElement(secondPaneRef.current!); + } else { + referencePaneSize.current = measureElement(firstPaneRef.current!); + } + const newKey = !pressedKeys.current.has(e.key); if (newKey) { @@ -254,7 +237,7 @@ export function useSplitter(options: UseSplitterOptions) { } } }, - [direction, handlePressedKeys, handleSize, maxDimProp, measurementProp, minDimProp] + [direction, handlePressedKeys, , measurementProp, usePixels] ); const onKeyUp = useCallback( @@ -268,11 +251,12 @@ export function useSplitter(options: UseSplitterOptions) { pressedKeys.current.delete(e.key); - if (typeof primarySizeRef.current === 'number') { - onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current); + if (primarySizeRef.current !== null) { + const secondPanePixels = containerSize.current! - primarySizeRef.current - handleSize; + onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current, secondPanePixels); } }, - [direction, onSizeChanged] + [direction, onSizeChanged, handleSize] ); const onDoubleClick = useCallback(() => { @@ -280,13 +264,15 @@ export function useSplitter(options: UseSplitterOptions) { return; } - firstPaneRef.current.style.flexGrow = '0.5'; - secondPaneRef.current.style.flexGrow = '0.5'; - const dim = measureElement(firstPaneRef.current); - firstPaneMeasurements.current = dim; - primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; - splitterRef.current!.ariaValueNow = `${ariaValue(primarySizeRef.current, dim[minDimProp], dim[maxDimProp])}`; - }, [maxDimProp, measurementProp, minDimProp]); + if (usePixels) { + secondPaneRef.current.style.flexBasis = `${initialSize}px`; + } else { + firstPaneRef.current.style.flexGrow = '0.5'; + secondPaneRef.current.style.flexGrow = '0.5'; + primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp]; + splitterRef.current!.ariaValueNow = `50`; + } + }, [measurementProp, usePixels, initialSize]); const onBlur = useCallback(() => { // If focus is lost while keys are held, stop changing panel sizes @@ -295,10 +281,11 @@ export function useSplitter(options: UseSplitterOptions) { dragStart.current = null; if (typeof primarySizeRef.current === 'number') { - onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current); + const secondPanePixels = containerSize.current! - primarySizeRef.current - handleSize; + onSizeChanged?.(parseFloat(firstPaneRef.current!.style.flexGrow), primarySizeRef.current, secondPanePixels); } } - }, [onSizeChanged]); + }, [onSizeChanged, handleSize]); const styles = useStyles2(getStyles, direction); const dragStyles = useStyles2(getDragStyles, dragPosition); @@ -306,7 +293,7 @@ export function useSplitter(options: UseSplitterOptions) { const id = useId(); const primaryStyles: React.CSSProperties = { - flexGrow: clamp(initialSize ?? 0.5, 0, 1), + flexGrow: clamp(initialSize, 0, 1), [minDimProp]: 'min-content', }; @@ -315,6 +302,12 @@ export function useSplitter(options: UseSplitterOptions) { [minDimProp]: 'min-content', }; + if (usePixels) { + primaryStyles.flexGrow = 1; + secondaryStyles.flexGrow = 'unset'; + secondaryStyles.flexBasis = `${initialSize}px`; + } + return { containerProps: { ref: containerRef, @@ -363,49 +356,34 @@ interface MeasureResult { maxHeight: number; } -function measureElement(ref: T): MeasureResult { +function measureElement(ref: T, usePixels?: boolean): MeasureResult { const savedBodyOverflow = document.body.style.overflow; const savedWidth = ref.style.width; const savedHeight = ref.style.height; const savedFlex = ref.style.flexGrow; + const savedFlexBasis = ref.style.flexBasis; document.body.style.overflow = 'hidden'; + ref.style.flexGrow = '0'; + ref.style.flexBasis = '0'; const { width: minWidth, height: minHeight } = ref.getBoundingClientRect(); ref.style.flexGrow = '100'; + const { width: maxWidth, height: maxHeight } = ref.getBoundingClientRect(); document.body.style.overflow = savedBodyOverflow; + ref.style.width = savedWidth; ref.style.height = savedHeight; ref.style.flexGrow = savedFlex; + ref.style.flexBasis = savedFlexBasis; return { minWidth, maxWidth, minHeight, maxHeight }; } -function useResizeObserver( - target: Element, - cb: (entries: ResizeObserverEntry[]) => void, - throttleWait = 0, - deps?: React.DependencyList -) { - const throttledCallback = throttle(cb, throttleWait); - - useLayoutEffect(() => { - if (!target) { - return; - } - - const resizeObserver = new ResizeObserver(throttledCallback); - - resizeObserver.observe(target, { box: 'device-pixel-content-box' }); - return () => resizeObserver.disconnect(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); -} - function getStyles(theme: GrafanaTheme2, direction: UseSplitterOptions['direction']) { return { container: css({ diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index 2a77fc83e87..3e2dc0764a6 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -43,14 +43,11 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls useSnappingSplitter({ direction: 'row', dragPosition: 'end', - initialSize: 0.8, + initialSize: 330, handleSize: 'sm', + usePixels: true, + collapseBelowPixels: 250, collapsed: isCollapsed, - - paneOptions: { - collapseBelowPixels: 150, - snapOpenToPixels: 400, - }, }); useEffect(() => { @@ -116,7 +113,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
0} /> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index c5b58cecc65..683d4a5d394 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -26,12 +26,10 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) useSnappingSplitter({ direction: 'row', dragPosition: 'end', - initialSize: 0.8, + initialSize: 330, + usePixels: true, collapsed: isCollapsed, - paneOptions: { - collapseBelowPixels: 250, - snapOpenToPixels: 400, - }, + collapseBelowPixels: 250, }); useEffect(() => { @@ -87,9 +85,7 @@ function VizAndDataPane({ model }: SceneComponentProps) { direction: 'column', dragPosition: 'start', initialSize: 0.5, - paneOptions: { - collapseBelowPixels: 150, - }, + collapseBelowPixels: 150, }); containerProps.className = cx(containerProps.className, styles.container); diff --git a/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts b/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts index 0d9c7bf9000..67e97e9dac9 100644 --- a/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts +++ b/public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.ts @@ -5,20 +5,17 @@ import { ComponentSize, DragHandlePosition, useSplitter } from '@grafana/ui'; export interface UseSnappingSplitterOptions { /** * The initial size of the primary pane between 0-1, defaults to 0.5 + * If `usePixels` is true, this is the initial size in pixels of the second pane. */ initialSize?: number; direction: 'row' | 'column'; dragPosition?: DragHandlePosition; - paneOptions: PaneOptions; collapsed?: boolean; // Size of the region left of the handle indicator that is responsive to dragging. At the same time acts as a margin // pushing the left pane content left. handleSize?: ComponentSize; -} - -interface PaneOptions { + usePixels?: boolean; collapseBelowPixels: number; - snapOpenToPixels?: number; } interface PaneState { @@ -26,56 +23,58 @@ interface PaneState { snapSize?: number; } -export function useSnappingSplitter(options: UseSnappingSplitterOptions) { - const { paneOptions } = options; - +export function useSnappingSplitter({ + direction, + initialSize, + dragPosition, + collapseBelowPixels, + collapsed, + handleSize, + usePixels, +}: UseSnappingSplitterOptions) { const [state, setState] = useState({ - collapsed: options.collapsed ?? false, - snapSize: options.collapsed ? 0 : undefined, + collapsed: collapsed ?? false, + snapSize: collapsed ? 0 : undefined, }); const onResizing = useCallback( - (flexSize: number, pixelSize: number) => { - if (flexSize <= 0 && pixelSize <= 0) { + (flexSize: number, firstPanePixels: number, secondPanePixels: number) => { + if (flexSize <= 0 && firstPanePixels <= 0 && secondPanePixels <= 0) { return; } - const optionsPixelSize = (pixelSize / flexSize) * (1 - flexSize); - - if (state.collapsed && optionsPixelSize > paneOptions.collapseBelowPixels) { + if (state.collapsed && secondPanePixels > collapseBelowPixels) { setState({ collapsed: false }); } - if (!state.collapsed && optionsPixelSize < paneOptions.collapseBelowPixels) { + if (!state.collapsed && secondPanePixels < collapseBelowPixels) { setState({ collapsed: true }); } }, - [state, paneOptions.collapseBelowPixels] + [state, collapseBelowPixels] ); const onSizeChanged = useCallback( - (flexSize: number, pixelSize: number) => { - if (flexSize <= 0 && pixelSize <= 0) { + (flexSize: number, firstPanePixels: number, secondPanePixels: number) => { + if (flexSize <= 0 && firstPanePixels <= 0 && secondPanePixels <= 0) { return; } - const newSecondPaneSize = 1 - flexSize; const isSnappedClosed = state.snapSize === 0; - const sizeOfBothPanes = pixelSize / flexSize; - const snapOpenToPixels = paneOptions.snapOpenToPixels ?? sizeOfBothPanes / 2; - const snapSize = snapOpenToPixels / sizeOfBothPanes; - if (state.collapsed) { - if (isSnappedClosed) { - setState({ snapSize: Math.max(newSecondPaneSize, snapSize), collapsed: false }); + if (state.collapsed && !isSnappedClosed) { + setState({ snapSize: 0, collapsed: state.collapsed }); + } else if (state.collapsed && isSnappedClosed) { + if (usePixels) { + const snapSize = Math.max(secondPanePixels, initialSize ?? 200); + setState({ snapSize, collapsed: !state.collapsed }); } else { - setState({ snapSize: 0, collapsed: true }); + const snapSize = Math.max(1 - (initialSize ?? 0.5), 1 - flexSize); + setState({ snapSize, collapsed: !state.collapsed }); } - } else if (isSnappedClosed) { - setState({ snapSize: newSecondPaneSize, collapsed: false }); } }, - [state, paneOptions.snapOpenToPixels] + [state, initialSize, usePixels] ); const onToggleCollapse = useCallback(() => { @@ -83,10 +82,11 @@ export function useSnappingSplitter(options: UseSnappingSplitterOptions) { }, [state.collapsed]); const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ - direction: options.direction, - dragPosition: options.dragPosition, - handleSize: options.handleSize, - initialSize: options.initialSize, + direction: direction, + dragPosition: dragPosition, + handleSize: handleSize, + initialSize: initialSize, + usePixels: usePixels, onResizing, onSizeChanged, }); @@ -97,17 +97,26 @@ export function useSnappingSplitter(options: UseSnappingSplitterOptions) { secondaryProps.style.minHeight = 'unset'; if (state.snapSize) { - primaryProps.style = { - ...primaryProps.style, - flexGrow: 1 - state.snapSize, - }; - secondaryProps.style.flexGrow = state.snapSize; + if (usePixels) { + secondaryProps.style.flexBasis = `${state.snapSize}px`; + } else { + primaryProps.style = { + ...primaryProps.style, + flexGrow: 1 - state.snapSize, + }; + secondaryProps.style.flexGrow = state.snapSize; + } } else if (state.snapSize === 0) { - primaryProps.style.flexGrow = 1; - secondaryProps.style.flexGrow = 0; - secondaryProps.style.minWidth = 'unset'; - secondaryProps.style.minHeight = 'unset'; + secondaryProps.style.minWidth = 'min-content'; + secondaryProps.style.minHeight = 'min-content'; secondaryProps.style.overflow = 'unset'; + + if (usePixels) { + secondaryProps.style.flexBasis = '0px'; + } else { + primaryProps.style.flexGrow = 1; + secondaryProps.style.flexGrow = 0; + } } return { containerProps, primaryProps, secondaryProps, splitterProps, splitterState: state, onToggleCollapse };