From 934fac67a6c014ac8d51f4428c94fa4e1c20c620 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Fri, 21 Mar 2025 16:28:24 +0200 Subject: [PATCH] Dynamic Dashboards: Improve drag and drop for responsive grid (#102613) --- .../components/PanelChrome/PanelChrome.tsx | 18 +++--- .../layout-manager/LayoutOrchestrator.tsx | 60 +++++++++++++------ .../ResponsiveGridLayout.tsx | 5 +- .../ResponsiveGridLayoutManager.tsx | 20 ++++--- .../ResponsiveGridLayoutRenderer.tsx | 4 +- 5 files changed, 66 insertions(+), 41 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 3346c110c51..7b534d88b09 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -151,7 +151,7 @@ export function PanelChrome({ const panelContentId = useId(); const panelTitleId = useId().replace(/:/g, '_'); const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId); - const pointerDownEvt = useRef(); + 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} diff --git a/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx index 2a221189fee..9789f85d005 100644 --- a/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx +++ b/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx @@ -14,6 +14,11 @@ export class LayoutOrchestrator extends SceneObjectBase /** 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 }) | 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 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 = { 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 }) | 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 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 } 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 } public findClosestDropZone(p: Point) { - const sceneLayouts = sceneGraph - .findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop) - .filter(isSceneLayoutWithDragAndDrop); let closestDropZone: (DropZone & { layout: SceneObjectRef }) | 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 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) { diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx index 3c705b34540..8a94c0d92fb 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx @@ -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 { diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 3cb79c576d4..0885e481c69 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -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({ diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx index 2139900b4c4..a3976fec4dc 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx @@ -59,8 +59,9 @@ export function ResponsiveGridLayoutRenderer({ model }: SceneComponentProps ({ position: 'relative', width: '100%', height: '100%', - overflow: 'hidden', }), dragging: css({ position: 'fixed',