Fix flickering when dragging AutoGrid items between grids

- Add isDroppedElsewhere() method to orchestrator for cross-layout detection
- Delay clearing draggingKey until item is moved to prevent source grid flicker
- Keep dropPosition/isDropTarget until draggedGridItemInside clears them
- Add endExternalDrag() to properly clean up CSS vars and draggingKey together
- Track container position to compensate for layout shifts during drag
This commit is contained in:
oscarkilhed
2026-01-08 09:22:09 +01:00
parent b444e42f05
commit 7ea4dd3744
3 changed files with 74 additions and 8 deletions
@@ -97,6 +97,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
document.body.removeEventListener('pointerup', this._stopDraggingSync);
document.body.removeEventListener('pointerup', this._onRowDragPointerUp);
this._clearTabActivationTimer();
this._clearDragPreview();
};
}
@@ -107,6 +108,14 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
return !!(this.state.draggingGridItem || this.state.draggingRow);
}
/**
* Returns true if the current drag operation will drop the item to a different layout
* than where it started. Used by AutoGridLayout to know whether to clear draggingKey.
*/
public isDroppedElsewhere(): boolean {
return this._lastDropTarget !== null && this._lastDropTarget !== this._sourceDropTarget;
}
public startDraggingSync(evt: ReactPointerEvent, gridItem: SceneGridItemLike): void {
this._pointerDistance.set(evt);
this._isSelectedObject = false;
@@ -137,9 +146,10 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
const sourceDropTarget = this._sourceDropTarget;
const lastDropTarget = this._lastDropTarget;
const dropPosition = this._currentDropPosition;
const isCrossLayoutDrop = sourceDropTarget !== lastDropTarget || wasDetached;
// Handle cross-layout or cross-tab drop
if (sourceDropTarget !== lastDropTarget || wasDetached) {
if (isCrossLayoutDrop) {
// Wrapped in setTimeout to ensure that any event handlers are called
// Useful for allowing react-grid-layout to remove placeholders, etc.
setTimeout(() => {
@@ -149,7 +159,16 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
sourceDropTarget?.draggedGridItemOutside?.(gridItem);
}
// Pass drop position for precise placement (AutoGrid uses this)
// Note: draggedGridItemInside also clears isDropTarget and dropPosition
lastDropTarget?.draggedGridItemInside?.(gridItem, dropPosition ?? undefined);
// Clean up source grid's drag state (CSS variables and draggingKey) after item is moved.
// This is done here (after movement) to prevent flickering where the item
// would momentarily appear at wrong position (CSS vars cleared but draggingKey set
// = absolute positioning with no valid position values).
if (sourceDropTarget instanceof AutoGridLayoutManager) {
sourceDropTarget.state.layout.endExternalDrag();
}
} else {
const warningMessage = 'No grid item to drag';
console.warn(warningMessage);
@@ -163,8 +182,19 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this._clearTabActivationTimer();
this._clearDragPreview();
this._clearDropPosition();
this._lastDropTarget?.setIsDropTarget?.(false);
// For cross-layout drops, don't clear drop position/target state synchronously.
// The placeholder should remain visible until the item is added by draggedGridItemInside,
// which also clears isDropTarget and dropPosition. This prevents flickering where the
// grid would momentarily shrink (placeholder removed) before expanding again (item added).
if (!isCrossLayoutDrop) {
this._clearDropPosition();
this._lastDropTarget?.setIsDropTarget?.(false);
}
// Clear internal tracking state (but not the visual state on the target for cross-layout drops)
this._currentDropPosition = null;
this._lastHoveredAutoGridItemKey = null;
this._lastDropTarget = null;
this._sourceDropTarget = null;
this._itemDetachedFromSource = false;
@@ -65,6 +65,8 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
top: number;
left: number;
} | null = null;
/** Container's initial page position, used to compensate for layout shifts during drag */
private _initialContainerRect: { top: number; left: number } | null = null;
private _lastDropTargetGridItemKey: string | null = null;
public constructor(state: Partial<AutoGridLayoutState>) {
@@ -150,6 +152,12 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
// Capture container's initial page position to compensate for layout shifts
// (e.g., when a grid above expands due to placeholder insertion)
const containerRect = this.containerRef.current?.getBoundingClientRect();
this._initialContainerRect = containerRect ? { top: containerRect.top, left: containerRect.left } : null;
this._updatePanelSize(width, height);
this._updatePanelPosition(top, left);
@@ -168,16 +176,33 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
this._draggedGridItem = null;
this._initialGridItemPosition = null;
this._initialContainerRect = null;
this._lastDropTargetGridItemKey = null;
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
// Only reset position/size and clear draggingKey if not dropping to a different layout.
// For cross-grid drops, the orchestrator will call endExternalDrag() after the item is moved
// to prevent flickering where the item would momentarily appear at wrong position
// (CSS vars cleared but draggingKey still set = absolute positioning with no position).
const orchestrator = getLayoutOrchestratorFor(this);
if (!orchestrator?.isDroppedElsewhere()) {
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
}
document.body.removeEventListener('pointermove', this._onDrag);
document.body.removeEventListener('pointerup', this._onDragEnd);
document.body.classList.remove('dashboard-draggable-transparent-selection');
}
/**
* Called by the orchestrator after a cross-layout drag ends and the item has been moved.
* Cleans up the drag state that was preserved during the cross-layout drop.
*/
public endExternalDrag(): void {
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
}
// Handle inside drag moves
private _onDrag(evt: PointerEvent) {
if (!this._draggedGridItem || !this._initialGridItemPosition) {
@@ -185,9 +210,20 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
return;
}
// Calculate how much the container has shifted since drag started
// This can happen when a grid above expands (e.g., placeholder causes row wrap)
let containerShiftY = 0;
let containerShiftX = 0;
if (this._initialContainerRect && this.containerRef.current) {
const currentRect = this.containerRef.current.getBoundingClientRect();
containerShiftY = currentRect.top - this._initialContainerRect.top;
containerShiftX = currentRect.left - this._initialContainerRect.left;
}
// Adjust position to compensate for container movement
this._updatePanelPosition(
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY),
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX)
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY) - containerShiftY,
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX) - containerShiftX
);
const dropTargetGridItemKey = document
@@ -101,6 +101,6 @@ const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
border: `1px dashed ${theme.colors.primary.main}`,
borderRadius: theme.shape.radius.default,
backgroundColor: theme.colors.primary.transparent,
minHeight: state.autoRows || '320px',
minHeight: '100px',
}),
});