Dynamic Dashboards: Improve drag and drop for responsive grid (#102613)

This commit is contained in:
Bogdan Matei
2025-03-21 16:28:24 +02:00
committed by GitHub
parent 08bbd7a536
commit 934fac67a6
5 changed files with 66 additions and 41 deletions
@@ -151,7 +151,7 @@ export function PanelChrome({
const panelContentId = useId();
const panelTitleId = useId().replace(/:/g, '_');
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
const pointerDownEvt = useRef<React.PointerEvent | undefined>();
const pointerDownPos = useRef<{ screenX: number; screenY: number }>({ screenX: 0, screenY: 0 });
const hasHeader = !hoverHeader;
@@ -203,11 +203,11 @@ export function PanelChrome({
evt.stopPropagation();
const distance = Math.hypot(
pointerDownEvt.current?.screenX ?? 0 - evt.screenX,
pointerDownEvt.current?.screenY ?? 0 - evt.screenY
pointerDownPos.current.screenX - evt.screenX,
pointerDownPos.current.screenY - evt.screenY
);
pointerDownEvt.current = undefined;
pointerDownPos.current = { screenX: 0, screenY: 0 };
// If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc)
if (
@@ -222,7 +222,10 @@ export function PanelChrome({
const onPointerDown = (evt: React.PointerEvent) => {
evt.stopPropagation();
pointerDownEvt.current = evt;
pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY };
onDragStart?.(evt);
};
const headerContent = (
@@ -350,11 +353,6 @@ export function PanelChrome({
className={cx(styles.headerContainer, dragClass)}
style={headerStyles}
data-testid="header-container"
onPointerMove={() => {
if (pointerDownEvt.current) {
onDragStart?.(pointerDownEvt.current);
}
}}
onPointerDown={onPointerDown}
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
@@ -14,6 +14,11 @@ export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState>
/** Offset from top-left corner of drag handle. */
public dragOffset = { top: 0, left: 0 };
/** The drop zone closest to the current mouse position while dragging. */
public activeDropZone: (DropZone & { layout: SceneObjectRef<SceneLayoutWithDragAndDrop> }) | undefined;
private _sceneLayouts: SceneLayoutWithDragAndDrop[] = [];
/** Used in `ResponsiveGridLayout`'s `onPointerDown` method */
public onDragStart = (e: PointerEvent, panel: VizPanel) => {
const closestLayoutItem = closestOfType(panel, isDashboardLayoutItem);
@@ -27,18 +32,39 @@ export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState>
return;
}
this._sceneLayouts = sceneGraph
.findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop)
.filter(isSceneLayoutWithDragAndDrop);
document.body.setPointerCapture(e.pointerId);
const targetRect = e.target.getBoundingClientRect();
this.dragOffset = { top: e.y - targetRect.top, left: e.x - targetRect.left };
this.setState({ activeLayoutItemRef: closestLayoutItem.getRef() });
closestLayoutItem.containerRef.current?.style.setProperty('--x-pos', `${e.x}px`);
closestLayoutItem.containerRef.current?.style.setProperty('--y-pos', `${e.y}px`);
const state: Partial<LayoutOrchestratorState> = { activeLayoutItemRef: closestLayoutItem.getRef() };
this._adjustXY({ x: e.x, y: e.y }, closestLayoutItem);
this.activeDropZone = this.findClosestDropZone({ x: e.clientX, y: e.clientY });
if (this.activeDropZone) {
state.placeholder = new DropZonePlaceholder({
top: this.activeDropZone.top,
left: this.activeDropZone.left,
width: this.activeDropZone.right - this.activeDropZone.left,
height: this.activeDropZone.bottom - this.activeDropZone.top,
});
}
this.setState(state);
document.addEventListener('pointermove', this.onDrag);
document.addEventListener('pointerup', this.onDragEnd);
document.body.classList.add('dragging-active');
};
/** The drop zone closest to the current mouse position while dragging. */
public activeDropZone: (DropZone & { layout: SceneObjectRef<SceneLayoutWithDragAndDrop> }) | undefined;
/** Called every tick while a panel is actively being dragged */
public onDrag = (e: PointerEvent) => {
const layoutItemContainer = this.state.activeLayoutItemRef?.resolve().containerRef.current;
@@ -49,9 +75,10 @@ export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState>
const cursorPos: Point = { x: e.clientX, y: e.clientY };
layoutItemContainer.style.setProperty('--x-pos', `${cursorPos.x}px`);
layoutItemContainer.style.setProperty('--y-pos', `${cursorPos.y}px`);
this._adjustXY(cursorPos);
const closestDropZone = this.findClosestDropZone(cursorPos);
if (!dropZonesAreEqual(this.activeDropZone, closestDropZone)) {
this.activeDropZone = closestDropZone;
if (this.activeDropZone) {
@@ -90,15 +117,7 @@ export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState>
}
this.moveLayoutItem(activeLayoutItem, targetLayout);
this.setState({
activeLayoutItemRef: undefined,
});
this.state.placeholder?.setState({
top: 0,
left: 0,
width: 0,
height: 0,
});
this.setState({ activeLayoutItemRef: undefined, placeholder: undefined });
this.activeDropZone = undefined;
activeLayoutItemContainer?.removeAttribute('style');
};
@@ -116,12 +135,9 @@ export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState>
}
public findClosestDropZone(p: Point) {
const sceneLayouts = sceneGraph
.findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop)
.filter(isSceneLayoutWithDragAndDrop);
let closestDropZone: (DropZone & { layout: SceneObjectRef<SceneLayoutWithDragAndDrop> }) | undefined = undefined;
let closestDistance = Number.MAX_VALUE;
for (const layout of sceneLayouts) {
for (const layout of this._sceneLayouts) {
const curClosestDropZone = layout.closestDropZone(p);
if (curClosestDropZone.distanceToPoint < closestDistance) {
closestDropZone = { ...curClosestDropZone, layout: layout.getRef() };
@@ -131,6 +147,12 @@ export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState>
return closestDropZone;
}
private _adjustXY(p: Point, activeLayoutItem = this.state.activeLayoutItemRef?.resolve()) {
const container = activeLayoutItem?.containerRef.current;
container?.style.setProperty('--x-pos', `${p.x}px`);
container?.style.setProperty('--y-pos', `${p.y}px`);
}
}
function dropZonesAreEqual(a?: DropZone, b?: DropZone) {
@@ -26,6 +26,9 @@ export interface ResponsiveGridLayoutState extends SceneObjectState, ResponsiveG
/** True when the items should be lazy loaded */
isLazy?: boolean;
/** True when the items should be draggable */
isDraggable?: boolean;
}
export interface ResponsiveGridLayoutOptions {
@@ -87,7 +90,7 @@ export class ResponsiveGridLayout
};
public isDraggable(): boolean {
return true;
return this.state.isDraggable ?? false;
}
public getDragClass(): string {
@@ -5,7 +5,12 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { NewObjectAddedToCanvasEvent, ObjectRemovedFromCanvasEvent } from '../../edit-pane/shared';
import { joinCloneKeys } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getGridItemKeyForPanelId, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import {
forceRenderChildren,
getGridItemKeyForPanelId,
getPanelIdForVizPanel,
getVizPanelKeyForPanelId,
} from '../../utils/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@@ -45,14 +50,6 @@ export class ResponsiveGridLayoutManager
autoRows: 'minmax(300px, auto)',
};
public constructor(state: ResponsiveGridLayoutManagerState) {
super(state);
// @ts-ignore
this.state.layout.getDragClassCancel = () => 'drag-cancel';
this.state.layout.isDraggable = () => true;
}
public addPanel(vizPanel: VizPanel) {
const panelId = dashboardSceneGraph.getNextPanelId(this);
@@ -131,6 +128,11 @@ export class ResponsiveGridLayoutManager
return panels;
}
public editModeChanged(isEditing: boolean) {
this.state.layout.setState({ isDraggable: isEditing });
forceRenderChildren(this.state.layout, true);
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
layout: this.state.layout.clone({
@@ -59,8 +59,9 @@ export function ResponsiveGridLayoutRenderer({ model }: SceneComponentProps<Resp
? {
width: item.cachedBoundingBox.right - item.cachedBoundingBox.left,
height: item.cachedBoundingBox.bottom - item.cachedBoundingBox.top,
// adjust the panel position to mouse position
translate: `${-layoutOrchestrator.dragOffset.left}px ${-layoutOrchestrator.dragOffset.top}px`,
// --x/y-pos are set in LayoutOrchestrator
// adjust the panel position on the screen
transform: `translate(var(--x-pos), var(--y-pos))`,
}
: {}
@@ -106,7 +107,6 @@ const getStyles = (theme: GrafanaTheme2, state: ResponsiveGridLayoutState) => ({
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
}),
dragging: css({
position: 'fixed',