diff --git a/public/app/features/canvas/runtime/frame.tsx b/public/app/features/canvas/runtime/frame.tsx
index b822c1e5f49..d09be5d7af5 100644
--- a/public/app/features/canvas/runtime/frame.tsx
+++ b/public/app/features/canvas/runtime/frame.tsx
@@ -1,7 +1,7 @@
import { cloneDeep } from 'lodash';
import React from 'react';
-import { CanvasFrameOptions, canvasElementRegistry } from 'app/features/canvas';
+import { canvasElementRegistry, CanvasFrameOptions } from 'app/features/canvas';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions';
import { LayerActionID } from 'app/plugins/panel/canvas/types';
@@ -74,6 +74,18 @@ export class FrameState extends ElementState {
this.reinitializeMoveable();
}
+ doMove(child: ElementState, action: LayerActionID) {
+ const vals = this.elements.filter((v) => v !== child);
+ if (action === LayerActionID.MoveBottom) {
+ vals.unshift(child);
+ } else {
+ vals.push(child);
+ }
+ this.elements = vals;
+ this.scene.save();
+ this.reinitializeMoveable();
+ }
+
reinitializeMoveable() {
// Need to first clear current selection and then re-init moveable with slight delay
this.scene.clearCurrentSelection();
@@ -151,6 +163,11 @@ export class FrameState extends ElementState {
this.scene.save();
this.reinitializeMoveable();
break;
+ case LayerActionID.MoveTop:
+ case LayerActionID.MoveBottom:
+ element.parent?.doMove(element, action);
+ break;
+
default:
console.log('DO action', action, element);
return;
diff --git a/public/app/features/canvas/runtime/root.tsx b/public/app/features/canvas/runtime/root.tsx
index 1ac9acbc9e9..9faebe93379 100644
--- a/public/app/features/canvas/runtime/root.tsx
+++ b/public/app/features/canvas/runtime/root.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { CanvasFrameOptions, CanvasElementOptions } from 'app/features/canvas';
+import { CanvasElementOptions, CanvasFrameOptions } from 'app/features/canvas';
import { FrameState } from './frame';
import { Scene } from './scene';
@@ -41,7 +41,12 @@ export class RootElement extends FrameState {
render() {
return (
-
+
event.preventDefault()}
+ key={this.UID}
+ ref={this.setRootRef}
+ style={{ ...this.sizeStyle, ...this.dataStyle }}
+ >
{this.elements.map((v) => v.render())}
);
diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx
index 5bd9aa2efce..2decff46999 100644
--- a/public/app/features/canvas/runtime/scene.tsx
+++ b/public/app/features/canvas/runtime/scene.tsx
@@ -6,7 +6,8 @@ import { first } from 'rxjs/operators';
import Selecto from 'selecto';
import { GrafanaTheme2, PanelData } from '@grafana/data';
-import { stylesFactory } from '@grafana/ui';
+import { locationService } from '@grafana/runtime/src';
+import { Portal, stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import { CanvasFrameOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
import {
@@ -24,6 +25,7 @@ import {
getScaleDimensionFromData,
getTextDimensionFromData,
} from 'app/features/dimensions/utils';
+import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
import { LayerActionID } from 'app/plugins/panel/canvas/types';
import { Placement } from '../types';
@@ -43,6 +45,7 @@ export class Scene {
readonly selection = new ReplaySubject
(1);
readonly moved = new Subject(); // called after resize/drag for editor updates
readonly byName = new Map();
+
root: RootElement;
revId = 0;
@@ -58,6 +61,8 @@ export class Scene {
isEditingEnabled?: boolean;
skipNextSelectionBroadcast = false;
+ isPanelEditing = locationService.getSearchObject().editPanel !== undefined;
+
constructor(cfg: CanvasFrameOptions, enableEditing: boolean, public onSave: (cfg: CanvasFrameOptions) => void) {
this.root = this.load(cfg, enableEditing);
}
@@ -314,6 +319,7 @@ export class Scene {
constraintViewable: allowChanges,
},
origin: false,
+ className: this.styles.selected,
})
.on('clickGroup', (event) => {
this.selecto!.clickTarget(event.inputEvent, event.inputTarget);
@@ -389,9 +395,16 @@ export class Scene {
};
render() {
+ const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled);
+
return (
{this.root.render()}
+ {canShowContextMenu && (
+
+
+
+ )}
);
}
@@ -402,4 +415,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
overflow: hidden;
position: relative;
`,
+ selected: css`
+ z-index: 999 !important;
+ `,
}));
diff --git a/public/app/plugins/panel/canvas/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/CanvasContextMenu.tsx
new file mode 100644
index 00000000000..3124406ccb5
--- /dev/null
+++ b/public/app/plugins/panel/canvas/CanvasContextMenu.tsx
@@ -0,0 +1,143 @@
+import { css } from '@emotion/css';
+import React, { useCallback, useEffect, useState } from 'react';
+import { first } from 'rxjs/operators';
+
+import { ContextMenu, MenuItem } from '@grafana/ui';
+
+import { Scene } from '../../../features/canvas/runtime/scene';
+
+import { LayerActionID } from './types';
+
+type Props = {
+ scene: Scene;
+};
+
+type AnchorPoint = {
+ x: number;
+ y: number;
+};
+
+export const CanvasContextMenu = ({ scene }: Props) => {
+ const [isMenuVisible, setIsMenuVisible] = useState(false);
+ const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 });
+
+ const styles = getStyles();
+
+ const selectedElements = scene.selecto?.getSelectedTargets();
+
+ const handleContextMenu = useCallback(
+ (event) => {
+ event.preventDefault();
+ if (event.currentTarget) {
+ scene.select({ targets: [event.currentTarget as HTMLElement | SVGElement] });
+ }
+ setAnchorPoint({ x: event.pageX, y: event.pageY });
+ setIsMenuVisible(true);
+ },
+ [scene]
+ );
+
+ useEffect(() => {
+ if (selectedElements && selectedElements.length === 1) {
+ const element = selectedElements[0];
+ element.addEventListener('contextmenu', handleContextMenu);
+ }
+ }, [selectedElements, handleContextMenu]);
+
+ if (!selectedElements) {
+ return <>>;
+ }
+
+ const closeContextMenu = () => {
+ setIsMenuVisible(false);
+ };
+
+ const renderMenuItems = () => {
+ return (
+ <>
+