Dynamic Dashboard: Drag and drop between grids, rows and tabs (#102714)
This commit is contained in:
@@ -151,7 +151,7 @@ export function PanelChrome({
|
||||
const panelContentId = useId();
|
||||
const panelTitleId = useId().replace(/:/g, '_');
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
|
||||
const pointerDownPos = useRef<{ screenX: number; screenY: number }>({ screenX: 0, screenY: 0 });
|
||||
const pointerDownLocation = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
const hasHeader = !hoverHeader;
|
||||
|
||||
@@ -198,35 +198,37 @@ export function PanelChrome({
|
||||
|
||||
// Handle drag & selection events
|
||||
// Mainly the tricky bit of differentiating between dragging and selecting
|
||||
const onPointerUp = React.useCallback(
|
||||
(evt: React.PointerEvent) => {
|
||||
const distance = Math.hypot(
|
||||
evt.clientX - pointerDownLocation.current.x,
|
||||
evt.clientY - pointerDownLocation.current.y
|
||||
);
|
||||
|
||||
const onPointerUp = (evt: React.PointerEvent) => {
|
||||
evt.stopPropagation();
|
||||
if (
|
||||
distance > 10 ||
|
||||
(dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = Math.hypot(
|
||||
pointerDownPos.current.screenX - evt.screenX,
|
||||
pointerDownPos.current.screenY - evt.screenY
|
||||
);
|
||||
// setTimeout is needed here because onSelect stops the event propagation
|
||||
// By doing so, the event won't get to the document and drag will never be stopped
|
||||
setTimeout(() => onSelect?.(evt));
|
||||
},
|
||||
[dragClassCancel, onSelect]
|
||||
);
|
||||
|
||||
pointerDownPos.current = { screenX: 0, screenY: 0 };
|
||||
const onPointerDown = React.useCallback(
|
||||
(evt: React.PointerEvent) => {
|
||||
evt.stopPropagation();
|
||||
|
||||
// If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc)
|
||||
if (
|
||||
distance > 10 ||
|
||||
(dragClassCancel && evt.target instanceof HTMLElement && evt.target.closest(`.${dragClassCancel}`))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pointerDownLocation.current = { x: evt.clientX, y: evt.clientY };
|
||||
|
||||
onSelect?.(evt);
|
||||
};
|
||||
|
||||
const onPointerDown = (evt: React.PointerEvent) => {
|
||||
evt.stopPropagation();
|
||||
|
||||
pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY };
|
||||
|
||||
onDragStart?.(evt);
|
||||
};
|
||||
onDragStart?.(evt);
|
||||
},
|
||||
[onDragStart]
|
||||
);
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
|
||||
@@ -105,5 +105,27 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
|
||||
// Universal style for marking drop targets when dragging between layouts
|
||||
'.dashboard-drop-target': {
|
||||
// Setting same options for hovered and not hovered to overwrite any conflicting styles
|
||||
// There was a race condition with selectable elements styles
|
||||
'&:is(:hover),&:not(:hover)': {
|
||||
outline: `2px solid ${theme.colors.primary.border}`,
|
||||
outlineOffset: '0px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
},
|
||||
|
||||
// Body style for preventing selection when dragging
|
||||
'.dashboard-draggable-transparent-selection': {
|
||||
'*::selection': {
|
||||
all: 'inherit',
|
||||
},
|
||||
},
|
||||
|
||||
'.react-draggable-dragging': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
|
||||
|
||||
interface DashboardLayoutOrchestratorState extends SceneObjectState {
|
||||
draggingPanel?: SceneObjectRef<VizPanel>;
|
||||
}
|
||||
|
||||
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
|
||||
private _sourceDropTarget: DashboardDropTarget | null = null;
|
||||
private _lastDropTarget: DashboardDropTarget | null = null;
|
||||
|
||||
public constructor() {
|
||||
super({});
|
||||
|
||||
this._onPointerMove = this._onPointerMove.bind(this);
|
||||
this._stopDraggingSync = this._stopDraggingSync.bind(this);
|
||||
|
||||
this.addActivationHandler(() => this._activationHandler());
|
||||
}
|
||||
|
||||
private _activationHandler() {
|
||||
return () => {
|
||||
document.body.removeEventListener('pointermove', this._onPointerMove);
|
||||
document.body.removeEventListener('pointerup', this._stopDraggingSync);
|
||||
};
|
||||
}
|
||||
|
||||
public startDraggingSync(_evt: ReactPointerEvent, panel: VizPanel): void {
|
||||
const dropTarget = sceneGraph.findObject(panel, isDashboardDropTarget);
|
||||
|
||||
if (!dropTarget || !isDashboardDropTarget(dropTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._sourceDropTarget = dropTarget;
|
||||
this._lastDropTarget = dropTarget;
|
||||
|
||||
document.body.addEventListener('pointermove', this._onPointerMove);
|
||||
document.body.addEventListener('pointerup', this._stopDraggingSync);
|
||||
|
||||
this.setState({ draggingPanel: panel.getRef() });
|
||||
}
|
||||
|
||||
private _stopDraggingSync(_evt: PointerEvent) {
|
||||
const panel = this.state.draggingPanel?.resolve();
|
||||
|
||||
if (this._sourceDropTarget !== this._lastDropTarget) {
|
||||
// Wrapped in setTimeout to ensure that any event handlers are called
|
||||
// Useful for allowing react-grid-layout to remove placeholders, etc.
|
||||
setTimeout(() => {
|
||||
this._sourceDropTarget?.draggedPanelOutside?.(panel!);
|
||||
this._lastDropTarget?.draggedPanelInside?.(panel!);
|
||||
});
|
||||
}
|
||||
|
||||
document.body.removeEventListener('pointermove', this._onPointerMove);
|
||||
document.body.removeEventListener('pointerup', this._stopDraggingSync);
|
||||
|
||||
this.setState({ draggingPanel: undefined });
|
||||
}
|
||||
|
||||
private _onPointerMove(evt: PointerEvent) {
|
||||
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
|
||||
|
||||
if (!dropTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropTarget !== this._lastDropTarget) {
|
||||
this._lastDropTarget?.setIsDropTarget?.(false);
|
||||
this._lastDropTarget = dropTarget;
|
||||
|
||||
if (dropTarget !== this._sourceDropTarget) {
|
||||
dropTarget.setIsDropTarget?.(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getDashboard(): DashboardScene {
|
||||
if (!(this.parent instanceof DashboardScene)) {
|
||||
throw new Error('Parent is not a DashboardScene');
|
||||
}
|
||||
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
private _getDropTargetUnderMouse(evt: MouseEvent): DashboardDropTarget | null {
|
||||
const key = document
|
||||
.elementsFromPoint(evt.clientX, evt.clientY)
|
||||
?.find((element) => {
|
||||
const key = element.getAttribute('data-dashboard-drop-target-key');
|
||||
|
||||
return !!key && key !== this._sourceDropTarget?.state.key;
|
||||
})
|
||||
?.getAttribute('data-dashboard-drop-target-key');
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sceneObject = sceneGraph.findByKey(this._getDashboard(), key);
|
||||
|
||||
if (!sceneObject || !isDashboardDropTarget(sceneObject)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sceneObject;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import { SchemaV2EditorDrawer } from '../v2schema/SchemaV2EditorDrawer';
|
||||
|
||||
import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer';
|
||||
import { DashboardControls } from './DashboardControls';
|
||||
import { DashboardLayoutOrchestrator } from './DashboardLayoutOrchestrator';
|
||||
import { DashboardSceneRenderer } from './DashboardSceneRenderer';
|
||||
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
|
||||
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
|
||||
@@ -70,8 +71,6 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang
|
||||
import { setupKeyboardShortcuts } from './keyboardShortcuts';
|
||||
import { DashboardGridItem } from './layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||
import { DropZonePlaceholder } from './layout-manager/DropZonePlaceholder';
|
||||
import { LayoutOrchestrator } from './layout-manager/LayoutOrchestrator';
|
||||
import { LayoutRestorer } from './layouts-shared/LayoutRestorer';
|
||||
import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew';
|
||||
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
|
||||
@@ -136,7 +135,7 @@ export interface DashboardSceneState extends SceneObjectState {
|
||||
editPane: DashboardEditPane;
|
||||
scopesBridge: SceneScopesBridge | undefined;
|
||||
/** Manages dragging/dropping of layout items */
|
||||
layoutOrchestrator: LayoutOrchestrator;
|
||||
layoutOrchestrator?: DashboardLayoutOrchestrator;
|
||||
}
|
||||
|
||||
export class DashboardScene extends SceneObjectBase<DashboardSceneState> implements LayoutParent {
|
||||
@@ -190,9 +189,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
...state,
|
||||
editPane: new DashboardEditPane(),
|
||||
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
|
||||
layoutOrchestrator: new LayoutOrchestrator({
|
||||
placeholder: new DropZonePlaceholder({ top: 0, left: 0, width: 0, height: 0 }),
|
||||
}),
|
||||
layoutOrchestrator: config.featureToggles.dashboardNewLayouts ? new DashboardLayoutOrchestrator() : undefined,
|
||||
});
|
||||
|
||||
this.serializer =
|
||||
|
||||
@@ -26,7 +26,6 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
scopesBridge,
|
||||
layoutOrchestrator,
|
||||
} = model.useState();
|
||||
const { placeholder } = layoutOrchestrator.useState();
|
||||
const { type } = useParams();
|
||||
const location = useLocation();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
@@ -74,7 +73,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
|
||||
return (
|
||||
<>
|
||||
{placeholder && <placeholder.Component model={placeholder} />}
|
||||
{layoutOrchestrator && <layoutOrchestrator.Component model={layoutOrchestrator} />}
|
||||
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
|
||||
{editPanel && <editPanel.Component model={editPanel} />}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { createRef } from 'react';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import {
|
||||
@@ -21,8 +20,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
|
||||
|
||||
import { getCloneKey } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { Point, Rect } from '../layout-manager/utils';
|
||||
import { DashboardLayoutItem, IntermediateLayoutItem } from '../types/DashboardLayoutItem';
|
||||
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
|
||||
|
||||
import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
|
||||
@@ -54,8 +52,6 @@ export class DashboardGridItem
|
||||
|
||||
private _gridSizeSub: Unsubscribable | undefined;
|
||||
|
||||
public containerRef = createRef<HTMLElement>();
|
||||
|
||||
public constructor(state: DashboardGridItemState) {
|
||||
super(state);
|
||||
|
||||
@@ -249,16 +245,4 @@ export class DashboardGridItem
|
||||
public isRepeated(): boolean {
|
||||
return this.state.variableName !== undefined;
|
||||
}
|
||||
|
||||
public toIntermediate(): IntermediateLayoutItem {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public distanceToPoint?(point: Point): number {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public boundingBox?(): Rect | undefined {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SceneComponentProps,
|
||||
SceneGridItemLike,
|
||||
useSceneObjectState,
|
||||
SceneGridLayoutDragStartEvent,
|
||||
} from '@grafana/scenes';
|
||||
import { GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { t } from 'app/core/internationalization';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
getVizPanelKeyForPanelId,
|
||||
getGridItemKeyForPanelId,
|
||||
useDashboard,
|
||||
getLayoutOrchestratorFor,
|
||||
} from '../../utils/utils';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
@@ -72,6 +74,14 @@ export class DefaultGridLayoutManager
|
||||
}
|
||||
|
||||
private _activationHandler() {
|
||||
if (config.featureToggles.dashboardNewLayouts) {
|
||||
this._subs.add(
|
||||
this.subscribeToEvent(SceneGridLayoutDragStartEvent, ({ payload: { evt, panel } }) =>
|
||||
getLayoutOrchestratorFor(this)?.startDraggingSync(evt, panel)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this._subs.add(
|
||||
this.state.grid.subscribeToState(({ children: newChildren }, { children: prevChildren }) => {
|
||||
if (newChildren.length === prevChildren.length) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||
import { Portal, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface DropZonePlaceholderState extends SceneObjectState {
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export class DropZonePlaceholder extends SceneObjectBase<DropZonePlaceholderState> {
|
||||
static Component = ({ model }: SceneComponentProps<DropZonePlaceholder>) => {
|
||||
const { width, height, left, top } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
className={cx(styles.placeholder, {
|
||||
[styles.visible]: width > 0 && height > 0,
|
||||
})}
|
||||
style={{ width, height, transform: `translate(${left}px, ${top}px)` }}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
placeholder: css({
|
||||
visibility: 'hidden',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
background: theme.colors.primary.transparent,
|
||||
boxShadow: `0 0 4px ${theme.colors.primary.border}`,
|
||||
}),
|
||||
visible: css({
|
||||
visibility: 'visible',
|
||||
}),
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { SceneObjectState, SceneObjectBase, SceneObjectRef, sceneGraph, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DashboardLayoutItem, isDashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
|
||||
import { DropZonePlaceholder } from './DropZonePlaceholder';
|
||||
import { closestOfType, DropZone, isSceneLayoutWithDragAndDrop, Point, SceneLayoutWithDragAndDrop } from './utils';
|
||||
|
||||
interface LayoutOrchestratorState extends SceneObjectState {
|
||||
activeLayoutItemRef?: SceneObjectRef<DashboardLayoutItem>;
|
||||
placeholder?: DropZonePlaceholder;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!closestLayoutItem) {
|
||||
console.warn('Unable to find layout item ancestor in panel hierarchy.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(e.target instanceof HTMLElement)) {
|
||||
console.warn('Target is not a HTML element.');
|
||||
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 };
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
/** Called every tick while a panel is actively being dragged */
|
||||
public onDrag = (e: PointerEvent) => {
|
||||
const layoutItemContainer = this.state.activeLayoutItemRef?.resolve().containerRef.current;
|
||||
if (!layoutItemContainer) {
|
||||
this.onDragEnd(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPos: Point = { x: e.clientX, y: e.clientY };
|
||||
|
||||
this._adjustXY(cursorPos);
|
||||
|
||||
const closestDropZone = this.findClosestDropZone(cursorPos);
|
||||
|
||||
if (!dropZonesAreEqual(this.activeDropZone, closestDropZone)) {
|
||||
this.activeDropZone = closestDropZone;
|
||||
if (this.activeDropZone) {
|
||||
this.setState({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the panel drag operation ends.
|
||||
* Clears up event listeners and any scratch state.
|
||||
*/
|
||||
public onDragEnd = (e: PointerEvent) => {
|
||||
document.removeEventListener('pointermove', this.onDrag);
|
||||
document.removeEventListener('pointerup', this.onDragEnd);
|
||||
document.body.releasePointerCapture(e.pointerId);
|
||||
document.body.classList.remove('dragging-active');
|
||||
|
||||
const activeLayoutItem = this.state.activeLayoutItemRef?.resolve();
|
||||
const activeLayoutItemContainer = activeLayoutItem?.containerRef.current;
|
||||
const targetLayout = this.activeDropZone?.layout.resolve();
|
||||
|
||||
if (!activeLayoutItem) {
|
||||
console.error('No active layout item');
|
||||
return;
|
||||
} else if (!targetLayout) {
|
||||
console.error('No target layout');
|
||||
return;
|
||||
}
|
||||
|
||||
this.moveLayoutItem(activeLayoutItem, targetLayout);
|
||||
this.setState({ activeLayoutItemRef: undefined, placeholder: undefined });
|
||||
this.activeDropZone = undefined;
|
||||
activeLayoutItemContainer?.removeAttribute('style');
|
||||
};
|
||||
|
||||
/** Moves layoutItem from its current layout to targetLayout to the location of the current placeholder.
|
||||
* Throws if layoutItem does not belong to any layout. */
|
||||
private moveLayoutItem(layoutItem: DashboardLayoutItem, targetLayout: SceneLayoutWithDragAndDrop) {
|
||||
const sourceLayout = closestOfType(layoutItem, isSceneLayoutWithDragAndDrop);
|
||||
if (!sourceLayout) {
|
||||
throw new Error(`Layout item with key "${layoutItem.state.key}" does not belong to any layout`);
|
||||
}
|
||||
|
||||
sourceLayout.removeLayoutItem(layoutItem);
|
||||
targetLayout.importLayoutItem(layoutItem);
|
||||
}
|
||||
|
||||
public findClosestDropZone(p: Point) {
|
||||
let closestDropZone: (DropZone & { layout: SceneObjectRef<SceneLayoutWithDragAndDrop> }) | undefined = undefined;
|
||||
let closestDistance = Number.MAX_VALUE;
|
||||
for (const layout of this._sceneLayouts) {
|
||||
const curClosestDropZone = layout.closestDropZone(p);
|
||||
if (curClosestDropZone.distanceToPoint < closestDistance) {
|
||||
closestDropZone = { ...curClosestDropZone, layout: layout.getRef() };
|
||||
closestDistance = curClosestDropZone.distanceToPoint;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const dims: Array<keyof DropZone> = ['top', 'left', 'bottom', 'right'];
|
||||
return a && b && dims.every((dim) => b[dim] === a[dim]);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { SceneLayout, SceneObject } from '@grafana/scenes';
|
||||
|
||||
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
export interface DropZone extends Rect {
|
||||
/* The two-dimensional euclidean distance, in pixels, between the drop zone and some reference point (usually cursor position) */
|
||||
distanceToPoint: number;
|
||||
}
|
||||
|
||||
export interface SceneLayoutWithDragAndDrop extends SceneLayout {
|
||||
closestDropZone(cursorPosition: Point): DropZone;
|
||||
importLayoutItem(layoutItem: DashboardLayoutItem): void;
|
||||
removeLayoutItem(layoutItem: DashboardLayoutItem): void;
|
||||
}
|
||||
|
||||
// todo@kay: Not the most robust interface check, should make more robust.
|
||||
export function isSceneLayoutWithDragAndDrop(o: SceneObject): o is SceneLayoutWithDragAndDrop {
|
||||
return (
|
||||
'isDraggable' in o &&
|
||||
'closestDropZone' in o &&
|
||||
typeof o.isDraggable === 'function' &&
|
||||
typeof o.closestDropZone === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/** Walks up the scene graph, returning the first non-undefined result of `extract` */
|
||||
export function getClosest<T>(sceneObject: SceneObject, extract: (s: SceneObject) => T | undefined): T | undefined {
|
||||
let curSceneObject: SceneObject | undefined = sceneObject;
|
||||
let extracted: T | undefined = undefined;
|
||||
|
||||
while (curSceneObject && !extracted) {
|
||||
extracted = extract(curSceneObject);
|
||||
curSceneObject = curSceneObject.parent;
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/** Walks up the scene graph, returning the first non-undefined result of `extract` */
|
||||
export function closestOfType<T extends SceneObject>(
|
||||
sceneObject: SceneObject,
|
||||
objectIsOfType: (s: SceneObject) => s is T
|
||||
): T | undefined {
|
||||
let curSceneObject: SceneObject | undefined = sceneObject;
|
||||
|
||||
while (curSceneObject && !objectIsOfType(curSceneObject)) {
|
||||
curSceneObject = curSceneObject.parent;
|
||||
}
|
||||
|
||||
return curSceneObject;
|
||||
}
|
||||
+18
-67
@@ -1,5 +1,4 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import { createRef } from 'react';
|
||||
|
||||
import {
|
||||
CustomVariable,
|
||||
@@ -19,12 +18,12 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
|
||||
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
|
||||
import { getCloneKey } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { Point, Rect } from '../layout-manager/utils';
|
||||
import { DashboardLayoutItem, IntermediateLayoutItem } from '../types/DashboardLayoutItem';
|
||||
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
|
||||
|
||||
import { getOptions } from './ResponsiveGridItemEditor';
|
||||
import { AutoGridItemRenderer } from './ResponsiveGridItemRenderer';
|
||||
import { AutoGridLayout } from './ResponsiveGridLayout';
|
||||
|
||||
export interface AutoGridItemState extends SceneObjectState {
|
||||
body: VizPanel;
|
||||
@@ -37,14 +36,17 @@ export interface AutoGridItemState extends SceneObjectState {
|
||||
|
||||
export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements DashboardLayoutItem {
|
||||
public static Component = AutoGridItemRenderer;
|
||||
private _prevRepeatValues?: VariableValueSingle[];
|
||||
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
variableNames: this.state.variableName ? [this.state.variableName] : [],
|
||||
onVariableUpdateCompleted: () => this.performRepeat(),
|
||||
});
|
||||
|
||||
public readonly isDashboardLayoutItem = true;
|
||||
public containerRef = createRef<HTMLDivElement>();
|
||||
public cachedBoundingBox: Rect | undefined;
|
||||
|
||||
public _containerRef: HTMLDivElement | null = null;
|
||||
|
||||
private _prevRepeatValues?: VariableValueSingle[];
|
||||
|
||||
public constructor(state: AutoGridItemState) {
|
||||
super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() });
|
||||
@@ -143,77 +145,26 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
|
||||
this.performRepeat();
|
||||
}
|
||||
|
||||
public computeBoundingBox() {
|
||||
const itemContainer = this.containerRef.current;
|
||||
if (!itemContainer || this.state.isHidden) {
|
||||
// We can't actually calculate the dimensions of the rendered grid item :(
|
||||
throw new Error('Unable to compute bounding box.');
|
||||
public getParentGrid(): AutoGridLayout {
|
||||
if (!(this.parent instanceof AutoGridLayout)) {
|
||||
throw new Error('Parent is not a ResponsiveGridLayout');
|
||||
}
|
||||
|
||||
this.cachedBoundingBox = itemContainer.getBoundingClientRect();
|
||||
return this.cachedBoundingBox;
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
public distanceToPoint(point: Point): number {
|
||||
if (!this.cachedBoundingBox) {
|
||||
try {
|
||||
this.cachedBoundingBox = this.computeBoundingBox();
|
||||
} catch (err) {
|
||||
// If we can't actually calculate the dimensions and position of the
|
||||
// rendered grid item, it might as well be infinitely far away.
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
const { top, left, bottom, right } = this.cachedBoundingBox;
|
||||
const corners: Point[] = [
|
||||
{ x: left, y: top },
|
||||
{ x: left, y: bottom },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
];
|
||||
|
||||
const { distance } = closestPoint(point, ...corners);
|
||||
return distance;
|
||||
public setRef(ref: HTMLDivElement | null) {
|
||||
this._containerRef = ref;
|
||||
}
|
||||
|
||||
toIntermediate(): IntermediateLayoutItem {
|
||||
const gridItem = this.containerRef.current;
|
||||
|
||||
if (!gridItem) {
|
||||
throw new Error('Grid item not found. Unable to convert to intermediate representation');
|
||||
}
|
||||
|
||||
// calculate origin and bounding box of layout item
|
||||
const rect = gridItem.getBoundingClientRect();
|
||||
public getBoundingBox(): { width: number; height: number; top: number; left: number } {
|
||||
const rect = this._containerRef!.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
body: this.state.body,
|
||||
origin: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
},
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: this._containerRef!.offsetTop,
|
||||
left: this._containerRef!.offsetLeft,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// todo@kay: tests
|
||||
function closestPoint(referencePoint: Point, ...points: Point[]): { point: Point; distance: number } {
|
||||
let minDistance = Number.POSITIVE_INFINITY;
|
||||
let closestPoint = points[0];
|
||||
for (const currentPoint of points) {
|
||||
const distance = euclideanDistance(referencePoint, currentPoint);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestPoint = currentPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return { point: closestPoint, distance: minDistance };
|
||||
}
|
||||
|
||||
function euclideanDistance(a: Point, b: Point): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
+57
-10
@@ -1,33 +1,80 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useDashboardState, useIsConditionallyHidden } from '../../utils/utils';
|
||||
|
||||
import { AutoGridItem } from './ResponsiveGridItem';
|
||||
import { DRAGGED_ITEM_HEIGHT, DRAGGED_ITEM_LEFT, DRAGGED_ITEM_TOP, DRAGGED_ITEM_WIDTH } from './ResponsiveGridLayout';
|
||||
|
||||
export interface AutoGridItemProps extends SceneComponentProps<AutoGridItem> {}
|
||||
|
||||
export function AutoGridItemRenderer({ model }: AutoGridItemProps) {
|
||||
const { body } = model.useState();
|
||||
export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem>) {
|
||||
const { body, repeatedPanels, key } = model.useState();
|
||||
const { draggingKey } = model.getParentGrid().useState();
|
||||
const { isEditing } = useDashboardState(model);
|
||||
const isConditionallyHidden = useIsConditionallyHidden(model);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (isConditionallyHidden && !isEditing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return model.state.repeatedPanels ? (
|
||||
const isDragging = !!draggingKey;
|
||||
const isDragged = draggingKey === key;
|
||||
|
||||
return repeatedPanels ? (
|
||||
<>
|
||||
{model.state.repeatedPanels.map((item) => (
|
||||
<div className={cx({ 'dashboard-visible-hidden-element': isConditionallyHidden })} key={item.state.key}>
|
||||
{repeatedPanels.map((item) => (
|
||||
<div
|
||||
className={cx(isConditionallyHidden && 'dashboard-visible-hidden-element', styles.wrapper)}
|
||||
key={item.state.key}
|
||||
>
|
||||
<item.Component model={item} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className={cx({ 'dashboard-visible-hidden-element': isConditionallyHidden })}>
|
||||
<body.Component model={body} />
|
||||
<div ref={(ref) => model.setRef(ref)} data-auto-grid-item-drop-target={isDragging ? key : undefined}>
|
||||
{isDragged && <div className={styles.draggedPlaceholder} />}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
isConditionallyHidden && 'dashboard-visible-hidden-element',
|
||||
styles.wrapper,
|
||||
isDragged && styles.draggedWrapper
|
||||
)}
|
||||
>
|
||||
<body.Component model={body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}),
|
||||
wrapper: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
draggedWrapper: css({
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
top: `var(${DRAGGED_ITEM_TOP})`,
|
||||
left: `var(${DRAGGED_ITEM_LEFT})`,
|
||||
width: `var(${DRAGGED_ITEM_WIDTH})`,
|
||||
height: `var(${DRAGGED_ITEM_HEIGHT})`,
|
||||
opacity: 0.8,
|
||||
}),
|
||||
draggedPlaceholder: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxShadow: `0 0 ${theme.spacing(0.5)} ${theme.colors.primary.border}`,
|
||||
background: `${theme.colors.primary.transparent}`,
|
||||
zIndex: -1,
|
||||
}),
|
||||
});
|
||||
|
||||
+132
-102
@@ -1,11 +1,8 @@
|
||||
import { createRef, CSSProperties, PointerEvent } from 'react';
|
||||
import { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import { LayoutOrchestrator } from '../layout-manager/LayoutOrchestrator';
|
||||
import { DropZone, Point, Rect, SceneLayoutWithDragAndDrop } from '../layout-manager/utils';
|
||||
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
import { getLayoutOrchestratorFor } from '../../utils/utils';
|
||||
|
||||
import { AutoGridItem } from './ResponsiveGridItem';
|
||||
import { AutoGridLayoutRenderer } from './ResponsiveGridLayoutRenderer';
|
||||
@@ -29,6 +26,9 @@ export interface AutoGridLayoutState extends SceneObjectState, AutoGridLayoutOpt
|
||||
|
||||
/** True when the items should be draggable */
|
||||
isDraggable?: boolean;
|
||||
|
||||
/** The key of the item being dragged */
|
||||
draggingKey?: string;
|
||||
}
|
||||
|
||||
export interface AutoGridLayoutOptions {
|
||||
@@ -55,19 +55,17 @@ export interface AutoGridLayoutOptions {
|
||||
justifyContent?: CSSProperties['justifyContent'];
|
||||
}
|
||||
|
||||
export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> implements SceneLayoutWithDragAndDrop {
|
||||
public layoutOrchestrator: LayoutOrchestrator | undefined;
|
||||
|
||||
export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> implements SceneLayout {
|
||||
public static Component = AutoGridLayoutRenderer;
|
||||
|
||||
public containerRef = createRef<HTMLDivElement>();
|
||||
|
||||
public activeIndex: number | undefined;
|
||||
public activeGridCell = { row: 1, column: 1 };
|
||||
public columnCount = 1;
|
||||
// maybe not needed?
|
||||
public rowCount = 1;
|
||||
public scrollPos: ReturnType<typeof closestScroll> | undefined;
|
||||
private _containerRef: HTMLDivElement | null = null;
|
||||
private _draggedGridItem: AutoGridItem | null = null;
|
||||
private _initialGridItemPosition: {
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
top: number;
|
||||
left: number;
|
||||
} | null = null;
|
||||
|
||||
public constructor(state: Partial<AutoGridLayoutState>) {
|
||||
super({
|
||||
@@ -79,12 +77,21 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
...state,
|
||||
});
|
||||
|
||||
this.addActivationHandler(this.activationHandler);
|
||||
this._onDragStart = this._onDragStart.bind(this);
|
||||
this._onDragEnd = this._onDragEnd.bind(this);
|
||||
this._onDrag = this._onDrag.bind(this);
|
||||
|
||||
this.addActivationHandler(() => this._activationHandler());
|
||||
}
|
||||
|
||||
private activationHandler = () => {
|
||||
this.layoutOrchestrator = getDashboardSceneFor(this).state.layoutOrchestrator;
|
||||
};
|
||||
private _activationHandler() {
|
||||
return () => {
|
||||
this._resetPanelPositionAndSize();
|
||||
document.body.removeEventListener('pointermove', this._onDrag);
|
||||
document.body.removeEventListener('pointerup', this._onDragEnd);
|
||||
document.body.classList.remove('dashboard-draggable-transparent-selection');
|
||||
};
|
||||
}
|
||||
|
||||
public isDraggable(): boolean {
|
||||
return this.state.isDraggable ?? false;
|
||||
@@ -98,110 +105,133 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
return 'grid-drag-cancel';
|
||||
}
|
||||
|
||||
public getDragHooks = () => {
|
||||
return { onDragStart: this.onPointerDown };
|
||||
};
|
||||
public getDragHooks() {
|
||||
return {
|
||||
onDragStart: this._onDragStart,
|
||||
};
|
||||
}
|
||||
|
||||
public onPointerDown = (e: PointerEvent, panel: VizPanel) => {
|
||||
const cannotDrag = this.cannotDrag(e.target);
|
||||
if (cannotDrag || !this.layoutOrchestrator) {
|
||||
public setRef(ref: HTMLDivElement | null) {
|
||||
this._containerRef = ref;
|
||||
}
|
||||
|
||||
private _canDrag(evt: ReactPointerEvent): boolean {
|
||||
if (!this.isDraggable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(evt.target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!evt.target.closest(`.${this.getDragClass()}`) && !evt.target.closest(`.${this.getDragClassCancel()}`);
|
||||
}
|
||||
|
||||
// Start inside dragging
|
||||
private _onDragStart(evt: ReactPointerEvent, panel: VizPanel) {
|
||||
if (!this._canDrag(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
// Refresh bounding boxes for all auto grid items
|
||||
for (const child of this.state.children) {
|
||||
child.computeBoundingBox();
|
||||
if (!(panel.parent instanceof AutoGridItem)) {
|
||||
throw new Error('Dragging wrong item');
|
||||
}
|
||||
|
||||
this.scrollPos = closestScroll(this.containerRef.current);
|
||||
this.layoutOrchestrator.onDragStart(e.nativeEvent, panel);
|
||||
};
|
||||
this._draggedGridItem = panel.parent;
|
||||
|
||||
private cannotDrag(el: EventTarget): boolean | Element {
|
||||
const dragClass = this.getDragClass();
|
||||
const dragCancelClass = this.getDragClassCancel();
|
||||
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
|
||||
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
|
||||
this._updatePanelSize(width, height);
|
||||
this._updatePanelPosition(top, left);
|
||||
|
||||
// cancel dragging if the element being interacted with has an ancestor with the drag cancel class set
|
||||
// or if the drag class isn't set on an ancestor
|
||||
return el instanceof Element && (el.closest(`.${dragCancelClass}`) || !el.closest(`.${dragClass}`));
|
||||
this.setState({ draggingKey: this._draggedGridItem.state.key });
|
||||
|
||||
document.body.addEventListener('pointermove', this._onDrag);
|
||||
document.body.addEventListener('pointerup', this._onDragEnd);
|
||||
document.body.classList.add('dashboard-draggable-transparent-selection');
|
||||
|
||||
getLayoutOrchestratorFor(this)?.startDraggingSync(evt, panel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the drop zone in this layout closest to the provided `point`.
|
||||
* This gets called every tick while a layout item is being dragged, so we use the grid item's cached bbox,
|
||||
* calculated whenever the layout changes, rather than calculating them every time the cursor position changes.
|
||||
*/
|
||||
public closestDropZone(point: Point): DropZone {
|
||||
let minDistance = Number.POSITIVE_INFINITY;
|
||||
let closestRect: Rect = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
// Stop inside dragging
|
||||
private _onDragEnd() {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
let closestIndex: number | undefined;
|
||||
let closest = { row: 1, column: 1 };
|
||||
this.state.children.forEach((gridItem, i) => {
|
||||
let curColumn = i % this.columnCount;
|
||||
let curRow = Math.floor(i / this.columnCount);
|
||||
const distance = gridItem.distanceToPoint(point);
|
||||
if (distance < minDistance && gridItem.cachedBoundingBox) {
|
||||
minDistance = distance;
|
||||
const { top, bottom, left, right } = gridItem.cachedBoundingBox;
|
||||
closestRect = { top, bottom, left, right };
|
||||
closestIndex = i;
|
||||
// css grid rows/columns are 1-indexed
|
||||
closest = { row: curRow + 1, column: curColumn + 1 };
|
||||
}
|
||||
});
|
||||
this._draggedGridItem = null;
|
||||
this._initialGridItemPosition = null;
|
||||
this._resetPanelPositionAndSize();
|
||||
|
||||
this.activeIndex = closestIndex;
|
||||
this.activeGridCell = closest;
|
||||
this.setState({ draggingKey: undefined });
|
||||
|
||||
return { ...closestRect, distanceToPoint: minDistance };
|
||||
document.body.removeEventListener('pointermove', this._onDrag);
|
||||
document.body.removeEventListener('pointerup', this._onDragEnd);
|
||||
document.body.classList.remove('dashboard-draggable-transparent-selection');
|
||||
}
|
||||
|
||||
public importLayoutItem(layoutItem: DashboardLayoutItem) {
|
||||
const layoutItemIR = layoutItem.toIntermediate();
|
||||
const layoutChildren = [...this.state.children];
|
||||
// Handle inside drag moves
|
||||
private _onDrag(evt: PointerEvent) {
|
||||
if (!this._draggedGridItem || !this._initialGridItemPosition) {
|
||||
this._onDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
layoutItemIR.body.clearParent();
|
||||
this._updatePanelPosition(
|
||||
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY),
|
||||
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX)
|
||||
);
|
||||
|
||||
const newLayoutItem = new AutoGridItem({ body: layoutItemIR.body });
|
||||
layoutChildren.splice(this.activeIndex ?? 0, 0, newLayoutItem);
|
||||
const dropTargetGridItemKey = document
|
||||
.elementsFromPoint(evt.clientX, evt.clientY)
|
||||
?.find((element) => {
|
||||
const key = element.getAttribute('data-auto-grid-item-drop-target');
|
||||
|
||||
this.setState({
|
||||
children: layoutChildren,
|
||||
});
|
||||
return !!key && key !== this._draggedGridItem!.state.key;
|
||||
})
|
||||
?.getAttribute('data-auto-grid-item-drop-target');
|
||||
|
||||
newLayoutItem.activate();
|
||||
if (dropTargetGridItemKey) {
|
||||
this._onDragOverItem(dropTargetGridItemKey);
|
||||
}
|
||||
}
|
||||
|
||||
public removeLayoutItem(layoutItem: DashboardLayoutItem) {
|
||||
this.setState({
|
||||
children: this.state.children.filter((c) => c !== layoutItem),
|
||||
});
|
||||
// Handle dragging an item from the same grid over another item from the same grid
|
||||
private _onDragOverItem(key: string) {
|
||||
const children = [...this.state.children];
|
||||
const draggedIdx = children.findIndex((child) => child === this._draggedGridItem);
|
||||
const draggedOverIdx = children.findIndex((child) => child.state.key === key);
|
||||
|
||||
layoutItem.clearParent();
|
||||
if (draggedIdx === -1 || draggedOverIdx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
children.splice(draggedIdx, 1);
|
||||
children.splice(draggedOverIdx, 0, this._draggedGridItem!);
|
||||
|
||||
this.setState({ children });
|
||||
}
|
||||
|
||||
private _updatePanelPosition(top: number, left: number) {
|
||||
this._containerRef?.style.setProperty(DRAGGED_ITEM_TOP, `${top}px`);
|
||||
this._containerRef?.style.setProperty(DRAGGED_ITEM_LEFT, `${left}px`);
|
||||
}
|
||||
|
||||
private _updatePanelSize(width: number, height: number) {
|
||||
this._containerRef?.style.setProperty(DRAGGED_ITEM_WIDTH, `${Math.floor(width)}px`);
|
||||
this._containerRef?.style.setProperty(DRAGGED_ITEM_HEIGHT, `${Math.floor(height)}px`);
|
||||
}
|
||||
|
||||
private _resetPanelPositionAndSize() {
|
||||
this._containerRef?.style.removeProperty(DRAGGED_ITEM_TOP);
|
||||
this._containerRef?.style.removeProperty(DRAGGED_ITEM_LEFT);
|
||||
this._containerRef?.style.removeProperty(DRAGGED_ITEM_WIDTH);
|
||||
this._containerRef?.style.removeProperty(DRAGGED_ITEM_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
function closestScroll(el?: HTMLElement | null): {
|
||||
scrollTop: number;
|
||||
scrollTopMax: number;
|
||||
wrapper?: HTMLElement | null;
|
||||
} {
|
||||
if (el && canScroll(el)) {
|
||||
return { scrollTop: el.scrollTop, scrollTopMax: el.scrollHeight - el.clientHeight - 5, wrapper: el };
|
||||
}
|
||||
|
||||
return el ? closestScroll(el.parentElement) : { scrollTop: 0, scrollTopMax: 0, wrapper: el };
|
||||
}
|
||||
|
||||
function canScroll(el: HTMLElement) {
|
||||
const oldScroll = el.scrollTop;
|
||||
el.scrollTop = Number.MAX_SAFE_INTEGER;
|
||||
const newScroll = el.scrollTop;
|
||||
el.scrollTop = oldScroll;
|
||||
|
||||
return newScroll > 0;
|
||||
}
|
||||
export const DRAGGED_ITEM_TOP = '--responsive-grid-dragged-item-top';
|
||||
export const DRAGGED_ITEM_LEFT = '--responsive-grid-dragged-item-left';
|
||||
export const DRAGGED_ITEM_WIDTH = '--responsive-grid-dragged-item-width';
|
||||
export const DRAGGED_ITEM_HEIGHT = '--responsive-grid-dragged-item-height';
|
||||
|
||||
+7
-5
@@ -8,6 +8,7 @@ import { joinCloneKeys } from '../../utils/clone';
|
||||
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
|
||||
import {
|
||||
forceRenderChildren,
|
||||
getDashboardSceneFor,
|
||||
getGridItemKeyForPanelId,
|
||||
getPanelIdForVizPanel,
|
||||
getVizPanelKeyForPanelId,
|
||||
@@ -72,14 +73,11 @@ export class AutoGridLayoutManager
|
||||
layout:
|
||||
state.layout ??
|
||||
new AutoGridLayout({
|
||||
isDraggable: true,
|
||||
templateColumns: getTemplateColumnsTemplate(maxColumnCount, columnWidth),
|
||||
autoRows: getAutoRowsTemplate(rowHeight, fillScreen),
|
||||
}),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
this.state.layout.getDragClassCancel = () => 'drag-cancel';
|
||||
this.state.layout.isDraggable = () => true;
|
||||
}
|
||||
|
||||
public addPanel(vizPanel: VizPanel) {
|
||||
@@ -168,6 +166,7 @@ export class AutoGridLayoutManager
|
||||
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
|
||||
return this.clone({
|
||||
layout: this.state.layout.clone({
|
||||
isDraggable: isSource && this.state.layout.state.isDraggable,
|
||||
children: this.state.layout.state.children.map((gridItem) => {
|
||||
if (gridItem instanceof AutoGridItem) {
|
||||
// Get the original panel ID from the gridItem's key
|
||||
@@ -240,7 +239,10 @@ export class AutoGridLayoutManager
|
||||
}
|
||||
|
||||
const layoutManager = AutoGridLayoutManager.createEmpty();
|
||||
layoutManager.state.layout.setState({ children });
|
||||
layoutManager.state.layout.setState({
|
||||
children,
|
||||
isDraggable: getDashboardSceneFor(layout).state.isEditing,
|
||||
});
|
||||
|
||||
return layoutManager;
|
||||
}
|
||||
|
||||
+11
-55
@@ -1,5 +1,4 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LazyLoader, SceneComponentProps } from '@grafana/scenes';
|
||||
@@ -14,64 +13,21 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
|
||||
const styles = useStyles2(getStyles, model.state);
|
||||
const { layoutOrchestrator } = getDashboardSceneFor(model).state;
|
||||
|
||||
const { activeLayoutItemRef } = layoutOrchestrator.useState();
|
||||
const activeLayoutItem = activeLayoutItemRef?.resolve();
|
||||
const currentLayoutIsActive = children.some((c) => c === activeLayoutItem);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.containerRef.current) {
|
||||
const computedStyles = getComputedStyle(model.containerRef.current);
|
||||
model.columnCount = computedStyles.gridTemplateColumns.split(' ').length;
|
||||
model.rowCount = computedStyles.gridTemplateRows.split(' ').length;
|
||||
|
||||
// when the contents of a scrollable area are changed, most (all?) browsers
|
||||
// seem to automatically adjust the scroll position
|
||||
// this hack keeps the scroll position fixed
|
||||
if (currentLayoutIsActive && model.scrollPos) {
|
||||
model.scrollPos.wrapper?.scrollTo(0, model.scrollPos.scrollTop);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isHidden || !layoutOrchestrator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={model.containerRef}>
|
||||
<div
|
||||
style={{
|
||||
gridRow: model.activeGridCell.row,
|
||||
gridColumn: model.activeGridCell.column,
|
||||
display: currentLayoutIsActive && model.activeIndex !== undefined ? 'grid' : 'none',
|
||||
}}
|
||||
/>
|
||||
{children.map((item) => {
|
||||
const Wrapper = isLazy ? LazyLoader : 'div';
|
||||
const isDragging = activeLayoutItem === item;
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
key={item.state.key!}
|
||||
className={cx(styles.wrapper, { [styles.dragging]: isDragging })}
|
||||
style={
|
||||
isDragging && layoutOrchestrator && item.cachedBoundingBox
|
||||
? {
|
||||
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`,
|
||||
// adjust the panel position on the screen
|
||||
transform: `translate(var(--x-pos), var(--y-pos))`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
ref={item.containerRef}
|
||||
>
|
||||
<item.Component model={item} />
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
<div className={styles.container} ref={(ref) => model.setRef(ref)}>
|
||||
{children.map((item) =>
|
||||
isLazy ? (
|
||||
<LazyLoader key={item.state.key!} className={styles.container}>
|
||||
<item.Component key={item.state.key} model={item} />
|
||||
</LazyLoader>
|
||||
) : (
|
||||
<item.Component key={item.state.key} model={item} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, VariableDependencyConfig } from '@grafana/scenes';
|
||||
import {
|
||||
sceneGraph,
|
||||
SceneObject,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
VariableDependencyConfig,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
@@ -8,6 +15,7 @@ import { getDefaultVizPanel } from '../../utils/utils';
|
||||
import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
|
||||
import { BulkActionElement } from '../types/BulkActionElement';
|
||||
import { DashboardDropTarget } from '../types/DashboardDropTarget';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
|
||||
import { LayoutParent } from '../types/LayoutParent';
|
||||
@@ -24,12 +32,13 @@ export interface RowItemState extends SceneObjectState {
|
||||
collapse?: boolean;
|
||||
hideHeader?: boolean;
|
||||
fillScreen?: boolean;
|
||||
isDropTarget?: boolean;
|
||||
conditionalRendering?: ConditionalRendering;
|
||||
}
|
||||
|
||||
export class RowItem
|
||||
extends SceneObjectBase<RowItemState>
|
||||
implements LayoutParent, BulkActionElement, EditableDashboardElement
|
||||
implements LayoutParent, BulkActionElement, EditableDashboardElement, DashboardDropTarget
|
||||
{
|
||||
public static Component = RowItemRenderer;
|
||||
|
||||
@@ -38,6 +47,7 @@ export class RowItem
|
||||
});
|
||||
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly isDashboardDropTarget = true;
|
||||
private _layoutRestorer = new LayoutRestorer();
|
||||
|
||||
public constructor(state?: Partial<RowItemState>) {
|
||||
@@ -129,6 +139,23 @@ export class RowItem
|
||||
return this._getParentLayout().isLastRow(this);
|
||||
}
|
||||
|
||||
public setIsDropTarget(isDropTarget: boolean) {
|
||||
if (!!this.state.isDropTarget !== isDropTarget) {
|
||||
this.setState({ isDropTarget });
|
||||
}
|
||||
}
|
||||
|
||||
public draggedPanelOutside(panel: VizPanel) {
|
||||
this.getLayout().removePanel?.(panel);
|
||||
this.setIsDropTarget(false);
|
||||
}
|
||||
|
||||
public draggedPanelInside(panel: VizPanel) {
|
||||
panel.clearParent();
|
||||
this.getLayout().addPanel(panel);
|
||||
this.setIsDropTarget(false);
|
||||
}
|
||||
|
||||
public getRepeatVariable(): string | undefined {
|
||||
return this._getRepeatBehavior()?.state.variableName;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { RowItem } from './RowItem';
|
||||
import { RowItemMenu } from './RowItemMenu';
|
||||
|
||||
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden } = model.useState();
|
||||
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget } = model.useState();
|
||||
const isClone = useIsClone(model);
|
||||
const { isEditing } = useDashboardState(model);
|
||||
const isConditionallyHidden = useIsConditionallyHidden(model);
|
||||
@@ -42,6 +42,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-dashboard-drop-target-key={model.state.key}
|
||||
className={cx(
|
||||
styles.wrapper,
|
||||
isEditing && !isCollapsed && styles.wrapperEditing,
|
||||
@@ -50,7 +51,8 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
shouldGrow && styles.wrapperGrow,
|
||||
isConditionallyHidden && 'dashboard-visible-hidden-element',
|
||||
!isClone && isSelected && 'dashboard-selected-element',
|
||||
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element'
|
||||
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element',
|
||||
isDropTarget && 'dashboard-drop-target'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
// If we selected and are clicking a button inside row header then don't de-select row
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
|
||||
import {
|
||||
SceneObjectState,
|
||||
SceneObjectBase,
|
||||
sceneGraph,
|
||||
VariableDependencyConfig,
|
||||
SceneObject,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
@@ -7,6 +14,7 @@ import { getDefaultVizPanel } from '../../utils/utils';
|
||||
import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
|
||||
import { BulkActionElement } from '../types/BulkActionElement';
|
||||
import { DashboardDropTarget } from '../types/DashboardDropTarget';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
|
||||
import { LayoutParent } from '../types/LayoutParent';
|
||||
@@ -19,11 +27,12 @@ import { TabsLayoutManager } from './TabsLayoutManager';
|
||||
export interface TabItemState extends SceneObjectState {
|
||||
layout: DashboardLayoutManager;
|
||||
title?: string;
|
||||
isDropTarget?: boolean;
|
||||
}
|
||||
|
||||
export class TabItem
|
||||
extends SceneObjectBase<TabItemState>
|
||||
implements LayoutParent, BulkActionElement, EditableDashboardElement
|
||||
implements LayoutParent, BulkActionElement, EditableDashboardElement, DashboardDropTarget
|
||||
{
|
||||
public static Component = TabItemRenderer;
|
||||
|
||||
@@ -32,6 +41,8 @@ export class TabItem
|
||||
});
|
||||
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly isDashboardDropTarget = true;
|
||||
|
||||
private _layoutRestorer = new LayoutRestorer();
|
||||
|
||||
constructor(state?: Partial<TabItemState>) {
|
||||
@@ -115,6 +126,29 @@ export class TabItem
|
||||
this.setState({ title });
|
||||
}
|
||||
|
||||
public setIsDropTarget(isDropTarget: boolean) {
|
||||
if (!!this.state.isDropTarget !== isDropTarget) {
|
||||
this.setState({ isDropTarget });
|
||||
}
|
||||
}
|
||||
|
||||
public draggedPanelOutside(panel: VizPanel) {
|
||||
this.getLayout().removePanel?.(panel);
|
||||
this.setIsDropTarget(false);
|
||||
}
|
||||
|
||||
public draggedPanelInside(panel: VizPanel) {
|
||||
panel.clearParent();
|
||||
this.getLayout().addPanel(panel);
|
||||
this.setIsDropTarget(false);
|
||||
|
||||
const parentLayout = this.getParentLayout();
|
||||
const tabIndex = parentLayout.state.tabs.findIndex((tab) => tab === this);
|
||||
if (tabIndex !== parentLayout.state.currentTabIndex) {
|
||||
parentLayout.setState({ currentTabIndex: tabIndex });
|
||||
}
|
||||
}
|
||||
|
||||
public getParentLayout(): TabsLayoutManager {
|
||||
return this._getParentLayout();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Tab, useElementSelection } from '@grafana/ui';
|
||||
import { TabItem } from './TabItem';
|
||||
|
||||
export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
const { title, key } = model.useState();
|
||||
const { title, key, isDropTarget } = model.useState();
|
||||
const parentLayout = model.getParentLayout();
|
||||
const { tabs, currentTabIndex } = parentLayout.useState();
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
@@ -25,7 +25,8 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
truncate
|
||||
className={cx(
|
||||
isSelected && 'dashboard-selected-element',
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element'
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element',
|
||||
isDropTarget && 'dashboard-drop-target'
|
||||
)}
|
||||
active={isActive}
|
||||
role="presentation"
|
||||
@@ -34,6 +35,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
aria-selected={isActive}
|
||||
onPointerDown={onSelect}
|
||||
label={titleInterpolated}
|
||||
data-dashboard-drop-target-key={model.state.key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SceneObject, VizPanel } from '@grafana/scenes';
|
||||
|
||||
export interface DashboardDropTarget extends SceneObject {
|
||||
isDashboardDropTarget: Readonly<true>;
|
||||
setIsDropTarget?(isDropTarget: boolean): void;
|
||||
draggedPanelOutside?(panel: VizPanel): void;
|
||||
draggedPanelInside?(panel: VizPanel): void;
|
||||
}
|
||||
|
||||
export function isDashboardDropTarget(scene: SceneObject): scene is DashboardDropTarget {
|
||||
return 'isDashboardDropTarget' in scene && scene.isDashboardDropTarget === true;
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import { RefObject } from 'react';
|
||||
|
||||
import { SceneObject, VizPanel } from '@grafana/scenes';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { Point, Rect } from '../layout-manager/utils';
|
||||
|
||||
export interface IntermediateLayoutItem {
|
||||
body: VizPanel;
|
||||
origin: Point;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction to handle editing of different layout elements (wrappers for VizPanels and other objects)
|
||||
* Also useful to when rendering / viewing an element outside it's layout scope
|
||||
@@ -22,11 +10,6 @@ export interface DashboardLayoutItem extends SceneObject {
|
||||
*/
|
||||
isDashboardLayoutItem: true;
|
||||
|
||||
/**
|
||||
* Reference to the container DOM element.
|
||||
*/
|
||||
containerRef: RefObject<HTMLElement>;
|
||||
|
||||
/**
|
||||
* Return layout item options (like repeat, repeat direction, etc. for the default DashboardGridItem)
|
||||
*/
|
||||
@@ -41,21 +24,6 @@ export interface DashboardLayoutItem extends SceneObject {
|
||||
* When coming out of panel edit
|
||||
*/
|
||||
editingCompleted?(withChanges: boolean): void;
|
||||
|
||||
/**
|
||||
* Converts the current layout item into an intermediate format.
|
||||
*/
|
||||
toIntermediate(): IntermediateLayoutItem;
|
||||
|
||||
/**
|
||||
* Calculates the distance from the current object to a specified point.
|
||||
*/
|
||||
distanceToPoint?(point: Point): number;
|
||||
|
||||
/**
|
||||
* Retrieves the bounding box of an element or object.
|
||||
*/
|
||||
boundingBox?(): Rect | undefined;
|
||||
}
|
||||
|
||||
export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { t } from 'app/core/internationalization';
|
||||
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
|
||||
|
||||
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
|
||||
import { DashboardLayoutOrchestrator } from '../scene/DashboardLayoutOrchestrator';
|
||||
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
@@ -477,3 +478,7 @@ export function useInterpolatedTitle<T extends SceneObjectState & { title?: stri
|
||||
|
||||
return sceneGraph.interpolate(scene, title, undefined, 'text');
|
||||
}
|
||||
|
||||
export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrchestrator | undefined {
|
||||
return getDashboardSceneFor(scene).state.layoutOrchestrator;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user