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 ( + <> + { + contextMenuAction(LayerActionID.Delete); + closeContextMenu(); + }} + className={styles.menuItem} + /> + { + contextMenuAction(LayerActionID.Duplicate); + closeContextMenu(); + }} + className={styles.menuItem} + /> + { + contextMenuAction(LayerActionID.MoveTop); + closeContextMenu(); + }} + className={styles.menuItem} + /> + { + contextMenuAction(LayerActionID.MoveBottom); + closeContextMenu(); + }} + className={styles.menuItem} + /> + + ); + }; + + const contextMenuAction = (actionType: string) => { + scene.selection.pipe(first()).subscribe((currentSelectedElements) => { + const currentSelectedElement = currentSelectedElements[0]; + const currentLayer = currentSelectedElement.parent!; + + switch (actionType) { + case LayerActionID.Delete: + currentLayer.doAction(LayerActionID.Delete, currentSelectedElement); + break; + case LayerActionID.Duplicate: + currentLayer.doAction(LayerActionID.Duplicate, currentSelectedElement); + break; + case LayerActionID.MoveTop: + currentLayer.doAction(LayerActionID.MoveTop, currentSelectedElement); + break; + case LayerActionID.MoveBottom: + currentLayer.doAction(LayerActionID.MoveBottom, currentSelectedElement); + break; + } + }); + }; + + if (isMenuVisible) { + return ( +
{ + event.preventDefault(); + closeContextMenu(); + }} + > + +
+ ); + } + + return <>; +}; + +const getStyles = () => ({ + menuItem: css` + max-width: 60ch; + overflow: hidden; + `, +});