Add visual placeholder for AutoGrid cross-grid drops
- Extend DashboardDropTarget interface with setDropPosition and position parameter - Make AutoGridLayoutManager implement DashboardDropTarget with placeholder support - Add placeholder rendering in AutoGridLayoutRenderer at dropPosition - Track hover position in orchestrator with left/right half detection for precise placement - Prevent flickering by tracking last hovered item key - Add draggedGridItemOutside to AutoGridLayoutManager to remove items from source - Clear row parent reference before adding to new layout in acceptDroppedRow
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
import { createPointerDistance, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { AutoGridLayoutManager } from './layout-auto-grid/AutoGridLayoutManager';
|
||||
import { RowItem } from './layout-rows/RowItem';
|
||||
import { RowsLayoutManager } from './layout-rows/RowsLayoutManager';
|
||||
import { TabItem } from './layout-tabs/TabItem';
|
||||
@@ -75,6 +76,10 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
private _sourceRowsLayout: RowsLayoutManager | null = null;
|
||||
/** Flag to track if row drag offset has been captured */
|
||||
private _rowOffsetCaptured = false;
|
||||
/** Current drop position for AutoGrid (index where item will be inserted) */
|
||||
private _currentDropPosition: number | null = null;
|
||||
/** Last hovered AutoGrid item key (to prevent flickering) */
|
||||
private _lastHoveredAutoGridItemKey: string | null = null;
|
||||
|
||||
public constructor() {
|
||||
super({});
|
||||
@@ -131,6 +136,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
// Capture these before cleanup since setTimeout runs after cleanup
|
||||
const sourceDropTarget = this._sourceDropTarget;
|
||||
const lastDropTarget = this._lastDropTarget;
|
||||
const dropPosition = this._currentDropPosition;
|
||||
|
||||
// Handle cross-layout or cross-tab drop
|
||||
if (sourceDropTarget !== lastDropTarget || wasDetached) {
|
||||
@@ -142,7 +148,8 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
if (!wasDetached) {
|
||||
sourceDropTarget?.draggedGridItemOutside?.(gridItem);
|
||||
}
|
||||
lastDropTarget?.draggedGridItemInside?.(gridItem);
|
||||
// Pass drop position for precise placement (AutoGrid uses this)
|
||||
lastDropTarget?.draggedGridItemInside?.(gridItem, dropPosition ?? undefined);
|
||||
} else {
|
||||
const warningMessage = 'No grid item to drag';
|
||||
console.warn(warningMessage);
|
||||
@@ -156,6 +163,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
|
||||
this._clearTabActivationTimer();
|
||||
this._clearDragPreview();
|
||||
this._clearDropPosition();
|
||||
this._lastDropTarget?.setIsDropTarget?.(false);
|
||||
this._lastDropTarget = null;
|
||||
this._sourceDropTarget = null;
|
||||
@@ -555,10 +563,13 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
|
||||
|
||||
if (!dropTarget) {
|
||||
this._clearDropPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropTarget !== this._lastDropTarget) {
|
||||
// Clear drop position from previous target
|
||||
this._clearDropPosition();
|
||||
this._lastDropTarget?.setIsDropTarget?.(false);
|
||||
this._lastDropTarget = dropTarget;
|
||||
|
||||
@@ -566,6 +577,76 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
dropTarget.setIsDropTarget?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update drop position for AutoGrid targets
|
||||
this._updateDropPosition(evt.clientX, evt.clientY, dropTarget);
|
||||
}
|
||||
|
||||
private _updateDropPosition(clientX: number, clientY: number, dropTarget: DashboardDropTarget): void {
|
||||
// Only update position for AutoGridLayoutManager targets
|
||||
if (!(dropTarget instanceof AutoGridLayoutManager)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show external placeholder when dragging within the same grid
|
||||
// (AutoGrid has its own internal drag placeholder)
|
||||
if (dropTarget === this._sourceDropTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find which AutoGridItem we're hovering over
|
||||
const elementsUnderPoint = document.elementsFromPoint(clientX, clientY);
|
||||
const targetElement = elementsUnderPoint?.find((el) => el.getAttribute('data-auto-grid-item-drop-target'));
|
||||
const targetKey = targetElement?.getAttribute('data-auto-grid-item-drop-target');
|
||||
|
||||
const children = dropTarget.state.layout.state.children;
|
||||
|
||||
// If not hovering over any item
|
||||
if (!targetKey || !targetElement) {
|
||||
// Only set initial position when first entering the grid
|
||||
if (this._currentDropPosition === null) {
|
||||
this._currentDropPosition = children.length;
|
||||
dropTarget.setDropPosition?.(children.length);
|
||||
}
|
||||
// Otherwise keep the current position (prevents flickering when over placeholder)
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we should insert before or after the hovered item
|
||||
// by checking if cursor is in left half or right half
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
const isRightHalf = clientX > rect.left + rect.width / 2;
|
||||
|
||||
// Create a composite key that includes both item key and side
|
||||
const compositeKey = `${targetKey}-${isRightHalf ? 'after' : 'before'}`;
|
||||
|
||||
// Only update if we're hovering over a different position than before
|
||||
// This prevents flickering when the placeholder shifts items around
|
||||
if (compositeKey === this._lastHoveredAutoGridItemKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastHoveredAutoGridItemKey = compositeKey;
|
||||
|
||||
// Find the index of the hovered item
|
||||
const hoveredIndex = children.findIndex((child) => child.state.key === targetKey);
|
||||
if (hoveredIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert after if in right half, before if in left half
|
||||
const newPosition = isRightHalf ? hoveredIndex + 1 : hoveredIndex;
|
||||
|
||||
this._currentDropPosition = newPosition;
|
||||
dropTarget.setDropPosition?.(newPosition);
|
||||
}
|
||||
|
||||
private _clearDropPosition(): void {
|
||||
if (this._currentDropPosition !== null && this._lastDropTarget) {
|
||||
this._lastDropTarget.setDropPosition?.(null);
|
||||
this._currentDropPosition = null;
|
||||
}
|
||||
this._lastHoveredAutoGridItemKey = null;
|
||||
}
|
||||
|
||||
private _getDashboard(): DashboardScene {
|
||||
|
||||
+13
-6
@@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||
import { LazyLoader, sceneGraph, SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
||||
@@ -12,6 +12,7 @@ import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelConte
|
||||
import { getIsLazy } from '../layouts-shared/utils';
|
||||
|
||||
import { AutoGridItem } from './AutoGridItem';
|
||||
import { AutoGridLayoutManager } from './AutoGridLayoutManager';
|
||||
import { DRAGGED_ITEM_HEIGHT, DRAGGED_ITEM_LEFT, DRAGGED_ITEM_TOP, DRAGGED_ITEM_WIDTH } from './const';
|
||||
|
||||
export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem>) {
|
||||
@@ -22,6 +23,10 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
const soloPanelContext = useSoloPanelContext();
|
||||
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
|
||||
|
||||
// Check if this grid is a drop target for external drags
|
||||
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
|
||||
const { isDropTarget } = layoutManager.useState();
|
||||
|
||||
const Wrapper = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react/display-name
|
||||
@@ -31,14 +36,14 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
conditionalRendering,
|
||||
addDndContainer,
|
||||
isDragged,
|
||||
isDragging,
|
||||
showDropTarget,
|
||||
isRepeat = false,
|
||||
}: {
|
||||
item: VizPanel;
|
||||
conditionalRendering?: ConditionalRenderingGroup;
|
||||
addDndContainer: boolean;
|
||||
isDragged: boolean;
|
||||
isDragging: boolean;
|
||||
showDropTarget: boolean;
|
||||
isRepeat?: boolean;
|
||||
}) => {
|
||||
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] =
|
||||
@@ -47,7 +52,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
return isConditionallyHidden && !isEditing && !renderHidden ? null : (
|
||||
<div
|
||||
{...(addDndContainer
|
||||
? { ref: model.containerRef, ['data-auto-grid-item-drop-target']: isDragging ? key : undefined }
|
||||
? { ref: model.containerRef, ['data-auto-grid-item-drop-target']: showDropTarget ? key : undefined }
|
||||
: {})}
|
||||
className={cx(isConditionallyHidden && !isEditing && styles.hidden)}
|
||||
>
|
||||
@@ -94,6 +99,8 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
|
||||
const isDragging = !!draggingKey;
|
||||
const isDragged = draggingKey === key;
|
||||
// Show drop target attribute for both internal drags and external drags (when this grid is a drop target)
|
||||
const showDropTarget = isDragging || !!isDropTarget;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -103,7 +110,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
addDndContainer={true}
|
||||
key={body.state.key!}
|
||||
isDragged={isDragged}
|
||||
isDragging={isDragging}
|
||||
showDropTarget={showDropTarget}
|
||||
/>
|
||||
{repeatedPanels.map((item, idx) => (
|
||||
<Wrapper
|
||||
@@ -112,7 +119,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
addDndContainer={false}
|
||||
key={item.state.key!}
|
||||
isDragged={isDragged}
|
||||
isDragging={isDragging}
|
||||
showDropTarget={showDropTarget}
|
||||
isRepeat={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
+62
-1
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../utils/utils';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { clearClipboard, getAutoGridItemFromClipboard } from '../layouts-shared/paste';
|
||||
import { DashboardDropTarget } from '../types/DashboardDropTarget';
|
||||
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
@@ -37,6 +38,10 @@ interface AutoGridLayoutManagerState extends SceneObjectState {
|
||||
rowHeight: AutoGridRowHeight;
|
||||
columnWidth: AutoGridColumnWidth;
|
||||
fillScreen: boolean;
|
||||
/** Whether this grid is currently a drop target */
|
||||
isDropTarget?: boolean;
|
||||
/** Position index where a placeholder should be shown for external drops */
|
||||
dropPosition?: number | null;
|
||||
}
|
||||
|
||||
export type AutoGridColumnWidth = 'narrow' | 'standard' | 'wide' | 'custom' | number;
|
||||
@@ -46,10 +51,14 @@ export const AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT = 3;
|
||||
export const AUTO_GRID_DEFAULT_COLUMN_WIDTH = 'standard';
|
||||
export const AUTO_GRID_DEFAULT_ROW_HEIGHT = 'standard';
|
||||
|
||||
export class AutoGridLayoutManager extends SceneObjectBase<AutoGridLayoutManagerState> implements DashboardLayoutGrid {
|
||||
export class AutoGridLayoutManager
|
||||
extends SceneObjectBase<AutoGridLayoutManagerState>
|
||||
implements DashboardLayoutGrid, DashboardDropTarget
|
||||
{
|
||||
public static Component = AutoGridLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
public readonly isDashboardDropTarget = true as const;
|
||||
|
||||
public static readonly descriptor: LayoutRegistryItem = {
|
||||
get name() {
|
||||
@@ -359,6 +368,58 @@ export class AutoGridLayoutManager extends SceneObjectBase<AutoGridLayoutManager
|
||||
|
||||
this.state.layout.setState({ children: [...this.state.layout.state.children, gridItem] });
|
||||
}
|
||||
|
||||
public setIsDropTarget(isDropTarget: boolean): void {
|
||||
this.setState({ isDropTarget });
|
||||
}
|
||||
|
||||
public setDropPosition(position: number | null): void {
|
||||
this.setState({ dropPosition: position });
|
||||
}
|
||||
|
||||
public draggedGridItemOutside(gridItem: SceneGridItemLike): void {
|
||||
if (gridItem instanceof AutoGridItem) {
|
||||
this.state.layout.setState({
|
||||
children: this.state.layout.state.children.filter((child) => child !== gridItem),
|
||||
});
|
||||
}
|
||||
this.setState({ isDropTarget: false });
|
||||
}
|
||||
|
||||
public draggedGridItemInside(gridItem: SceneGridItemLike, position?: number): void {
|
||||
let newGridItem: AutoGridItem;
|
||||
|
||||
if (gridItem instanceof AutoGridItem) {
|
||||
gridItem.clearParent();
|
||||
newGridItem = gridItem;
|
||||
} else if (gridItem instanceof DashboardGridItem) {
|
||||
if (!(gridItem.state.body instanceof VizPanel)) {
|
||||
throw new Error('DashboardGridItem body is not a VizPanel');
|
||||
}
|
||||
const panel = gridItem.state.body;
|
||||
panel.clearParent();
|
||||
|
||||
newGridItem = new AutoGridItem({
|
||||
body: panel,
|
||||
variableName: gridItem.state.variableName,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Grid item must be an AutoGridItem or DashboardGridItem');
|
||||
}
|
||||
|
||||
const children = [...this.state.layout.state.children];
|
||||
|
||||
if (position !== undefined && position >= 0 && position <= children.length) {
|
||||
// Insert at specific position
|
||||
children.splice(position, 0, newGridItem);
|
||||
} else {
|
||||
// Append to end
|
||||
children.push(newGridItem);
|
||||
}
|
||||
|
||||
this.state.layout.setState({ children });
|
||||
this.setState({ isDropTarget: false, dropPosition: null });
|
||||
}
|
||||
}
|
||||
|
||||
function AutoGridLayoutManagerRenderer({ model }: SceneComponentProps<AutoGridLayoutManager>) {
|
||||
|
||||
+35
-4
@@ -18,7 +18,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
|
||||
const styles = useStyles2(getStyles, model.state);
|
||||
const { layoutOrchestrator, isEditing } = useDashboardState(model);
|
||||
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
|
||||
const { fillScreen } = layoutManager.useState();
|
||||
const { fillScreen, dropPosition } = layoutManager.useState();
|
||||
const soloPanelContext = useSoloPanelContext();
|
||||
|
||||
if (isHidden || !layoutOrchestrator) {
|
||||
@@ -31,19 +31,44 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
|
||||
return children.map((item) => <item.Component key={item.state.key} model={item} />);
|
||||
}
|
||||
|
||||
// Build children with placeholder inserted at dropPosition
|
||||
const renderChildren = () => {
|
||||
if (dropPosition === null || dropPosition === undefined) {
|
||||
return children.map((item) => <item.Component key={item.state.key} model={item} />);
|
||||
}
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
const insertPosition = Math.min(dropPosition, children.length);
|
||||
|
||||
for (let i = 0; i <= children.length; i++) {
|
||||
if (i === insertPosition) {
|
||||
result.push(<DropPlaceholder key="drop-placeholder" styles={styles} />);
|
||||
}
|
||||
if (i < children.length) {
|
||||
const item = children[i];
|
||||
result.push(<item.Component key={item.state.key} model={item} />);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.container, fillScreen && styles.containerFillScreen, isEditing && styles.containerEditing)}
|
||||
ref={model.containerRef}
|
||||
data-dashboard-drop-target-key={layoutManager.state.key}
|
||||
>
|
||||
{children.map((item) => (
|
||||
<item.Component key={item.state.key} model={item} />
|
||||
))}
|
||||
{renderChildren()}
|
||||
{showCanvasActions && <CanvasGridAddActions layoutManager={layoutManager} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropPlaceholder({ styles }: { styles: ReturnType<typeof getStyles> }) {
|
||||
return <div className={styles.dropPlaceholder} />;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
|
||||
container: css({
|
||||
display: 'grid',
|
||||
@@ -72,4 +97,10 @@ const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
|
||||
}),
|
||||
containerFillScreen: css({ flexGrow: 1 }),
|
||||
containerEditing: css({ paddingBottom: theme.spacing(5), position: 'relative' }),
|
||||
dropPlaceholder: css({
|
||||
border: `1px dashed ${theme.colors.primary.main}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.primary.transparent,
|
||||
minHeight: state.autoRows || '320px',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -252,6 +252,9 @@ export class TabItem
|
||||
public acceptDroppedRow(row: RowItem): void {
|
||||
const currentLayout = this.getLayout();
|
||||
|
||||
// Clear the parent reference from the row before adding to new layout
|
||||
row.clearParent();
|
||||
|
||||
if (currentLayout instanceof RowsLayoutManager) {
|
||||
// Already has a RowsLayoutManager, just add the row
|
||||
currentLayout.addNewRow(row);
|
||||
|
||||
@@ -4,7 +4,9 @@ export interface DashboardDropTarget extends SceneObject {
|
||||
isDashboardDropTarget: Readonly<true>;
|
||||
setIsDropTarget?(isDropTarget: boolean): void;
|
||||
draggedGridItemOutside?(gridItem: SceneGridItemLike): void;
|
||||
draggedGridItemInside?(gridItem: SceneGridItemLike): void;
|
||||
draggedGridItemInside?(gridItem: SceneGridItemLike, position?: number): void;
|
||||
/** Set the position where a placeholder should be shown for external drops */
|
||||
setDropPosition?(position: number | null): void;
|
||||
}
|
||||
|
||||
export function isDashboardDropTarget(scene: SceneObject): scene is DashboardDropTarget {
|
||||
|
||||
Reference in New Issue
Block a user