Splitters: Support pixel mode for useSplitter and useSnappingSplitter (#103590)

* splitter pixels

* Progress

* Splitters working

* fixes

* Update edit pane width to 330

* update
pull/103451/head^2
Torkel Ödegaard 1 month ago committed by GitHub
parent af8a70bbab
commit 7f9e18282e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 230
      packages/grafana-ui/src/components/Splitter/useSplitter.ts
  2. 11
      public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx
  3. 12
      public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx
  4. 95
      public/app/features/dashboard-scene/panel-edit/splitter/useSnappingSplitter.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<HTMLDivElement | null>(null);
@ -56,43 +65,19 @@ export function useSplitter(options: UseSplitterOptions) {
const secondPaneRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const containerSize = useRef<number | null>(null);
const primarySizeRef = useRef<'1fr' | number>('1fr');
const firstPaneMeasurements = useRef<MeasureResult | undefined>(undefined);
const primarySizeRef = useRef<number | null>(null);
const referencePaneSize = useRef<MeasureResult | undefined>(undefined);
const savedPos = useRef<string | undefined>(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<number | null>(null);
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
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 onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (dragStart.current !== null && primarySizeRef.current !== '1fr') {
const diff = e[clientAxis] - dragStart.current;
const dims = firstPaneMeasurements.current!;
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]
);
onResizing?.(newFlex, newSize);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
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<string>());
@ -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);
firstPaneRef.current!.style.flexGrow = `${newFlex}`;
secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`;
splitterRef.current!.ariaValueNow = ariaValue(newSize, firstPaneDims[minDimProp], firstPaneDims[maxDimProp]);
// measure primary and container
primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp];
containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp];
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<T extends HTMLElement>(ref: T): MeasureResult {
function measureElement<T extends HTMLElement>(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({

@ -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
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}>
<DashboardEditPaneRenderer
editPane={editPane}
isCollapsed={splitterState.collapsed}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
openOverlay={selectionContext.selected.length > 0}
/>

@ -26,12 +26,10 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
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<PanelEditor>) {
direction: 'column',
dragPosition: 'start',
initialSize: 0.5,
paneOptions: {
collapseBelowPixels: 150,
},
collapseBelowPixels: 150,
});
containerProps.className = cx(containerProps.className, styles.container);

@ -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<PaneState>({
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 };

Loading…
Cancel
Save