From defbd72bf2a48b842937ed26ff1211b047fa3bc4 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Mon, 8 Nov 2021 11:05:16 -0800 Subject: [PATCH] Canvas: add multi layer functionality (#41326) --- public/app/features/canvas/runtime/scene.tsx | 86 ++++++++-- .../app/plugins/panel/canvas/CanvasPanel.tsx | 2 - .../canvas/editor/LayerElementListEditor.tsx | 158 +++++++++++++++--- .../canvas/editor/MultiSelectionEditor.tsx | 42 +++++ .../panel/canvas/editor/elementEditor.tsx | 2 +- .../panel/canvas/editor/elementsEditor.tsx | 24 +++ .../panel/canvas/editor/layerEditor.tsx | 38 ++++- public/app/plugins/panel/canvas/module.tsx | 8 + 8 files changed, 316 insertions(+), 44 deletions(-) create mode 100644 public/app/plugins/panel/canvas/editor/MultiSelectionEditor.tsx create mode 100644 public/app/plugins/panel/canvas/editor/elementsEditor.tsx diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index ffe9307660b..4d22c3cc730 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -23,6 +23,12 @@ import { } from 'app/features/dimensions/utils'; import { ElementState } from './element'; import { RootElement } from './root'; +import { GroupState } from './group'; + +export interface SelectionParams { + targets: Array; + group?: GroupState; +} export class Scene { styles = getStyles(config.theme2); @@ -37,7 +43,9 @@ export class Scene { style: CSSProperties = {}; data?: PanelData; selecto?: Selecto; + moveable?: Moveable; div?: HTMLDivElement; + currentLayer?: GroupState; constructor(cfg: CanvasGroupOptions, enableEditing: boolean, public onSave: (cfg: CanvasGroupOptions) => void) { this.root = this.load(cfg, enableEditing); @@ -91,6 +99,12 @@ export class Scene { this.selecto?.clickTarget(event, this.div); } + updateCurrentLayer(newLayer: GroupState) { + this.currentLayer = newLayer; + this.clearCurrentSelection(); + this.save(); + } + toggleAnchor(element: ElementState, k: keyof Anchor) { console.log('TODO, smarter toggle', element.UID, element.anchor, k); const { div } = element; @@ -133,18 +147,69 @@ export class Scene { }; private findElementByTarget = (target: HTMLElement | SVGElement): ElementState | undefined => { - return this.root.elements.find((element) => element.div === target); + // We will probably want to add memoization to this as we are calling on drag / resize + + const stack = [...this.root.elements]; + while (stack.length > 0) { + const currentElement = stack.shift(); + + if (currentElement && currentElement.div && currentElement.div === target) { + return currentElement; + } + + const nestedElements = currentElement instanceof GroupState ? currentElement.elements : []; + for (const nestedElement of nestedElements) { + stack.unshift(nestedElement); + } + } + + return undefined; }; setRef = (sceneContainer: HTMLDivElement) => { this.div = sceneContainer; }; + select = (selection: SelectionParams) => { + if (this.selecto) { + this.selecto.setSelectedTargets(selection.targets); + this.updateSelection(selection); + } + }; + + private updateSelection = (selection: SelectionParams) => { + this.moveable!.target = selection.targets; + + if (selection.group) { + this.selection.next([selection.group]); + } else { + const s = selection.targets.map((t) => this.findElementByTarget(t)!); + this.selection.next(s); + } + }; + + private generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => { + let targetElements: HTMLDivElement[] = []; + + const stack = [...rootElements]; + while (stack.length > 0) { + const currentElement = stack.shift(); + + if (currentElement && currentElement.div) { + targetElements.push(currentElement.div); + } + + const nestedElements = currentElement instanceof GroupState ? currentElement.elements : []; + for (const nestedElement of nestedElements) { + stack.unshift(nestedElement); + } + } + + return targetElements; + }; + initMoveable = (destroySelecto = false, allowChanges = true) => { - const targetElements: HTMLDivElement[] = []; - this.root.elements.forEach((element: ElementState) => { - targetElements.push(element.div!); - }); + const targetElements = this.generateTargetElements(this.root.elements); if (destroySelecto) { this.selecto?.destroy(); @@ -156,7 +221,7 @@ export class Scene { selectByClick: true, }); - const moveable = new Moveable(this.div!, { + this.moveable = new Moveable(this.div!, { draggable: allowChanges, resizable: allowChanges, origin: false, @@ -202,7 +267,7 @@ export class Scene { const selectedTarget = event.inputEvent.target; const isTargetMoveableElement = - moveable.isMoveableElement(selectedTarget) || + this.moveable!.isMoveableElement(selectedTarget) || targets.some((target) => target === selectedTarget || target.contains(selectedTarget)); if (isTargetMoveableElement) { @@ -211,15 +276,12 @@ export class Scene { } }).on('selectEnd', (event) => { targets = event.selected; - moveable.target = targets; - - const s = event.selected.map((t) => this.findElementByTarget(t)!); - this.selection.next(s); + this.updateSelection({ targets }); if (event.isDragStart) { event.inputEvent.preventDefault(); setTimeout(() => { - moveable.dragStart(event.inputEvent); + this.moveable!.dragStart(event.inputEvent); }); } }); diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 7e42bd01b06..31e5d4aeb02 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -7,7 +7,6 @@ import { CanvasGroupOptions } from 'app/features/canvas'; import { Scene } from 'app/features/canvas/runtime/scene'; import { PanelContext, PanelContextRoot } from '@grafana/ui'; import { ElementState } from 'app/features/canvas/runtime/element'; -import { GroupState } from 'app/features/canvas/runtime/group'; interface Props extends PanelProps {} @@ -18,7 +17,6 @@ interface State { export interface InstanceState { scene: Scene; selected: ElementState[]; - layer: GroupState; } export class CanvasPanel extends Component { diff --git a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx index 0c8e21e0bc6..0538bc10505 100644 --- a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx @@ -6,14 +6,17 @@ import { config } from '@grafana/runtime'; import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; import { PanelOptions } from '../models.gen'; -import { InstanceState } from '../CanvasPanel'; import { LayerActionID } from '../types'; import { CanvasElementOptions, canvasElementRegistry } from 'app/features/canvas'; import appEvents from 'app/core/app_events'; import { ElementState } from 'app/features/canvas/runtime/element'; import { notFoundItem } from 'app/features/canvas/elements/notFound'; +import { GroupState } from 'app/features/canvas/runtime/group'; +import { LayerEditorProps } from './layerEditor'; +import { SelectionParams } from 'app/features/canvas/runtime/scene'; +import { ShowConfirmModalEvent } from 'app/types/events'; -type Props = StandardEditorProps; +type Props = StandardEditorProps; export class LayerElementListEditor extends PureComponent { style = getLayerDragStyles(config.theme); @@ -40,9 +43,23 @@ export class LayerElementListEditor extends PureComponent { onSelect = (item: any) => { const { settings } = this.props.item; - if (settings?.scene && settings?.scene?.selecto) { + if (settings?.scene) { try { - settings.scene.selecto.clickTarget(item, item?.div); + let selection: SelectionParams = { targets: [] }; + if (item instanceof GroupState) { + const targetElements: HTMLDivElement[] = []; + item.elements.forEach((element: ElementState) => { + targetElements.push(element.div!); + }); + + selection.targets = targetElements; + selection.group = item; + settings.scene.select(selection); + } else if (item instanceof ElementState) { + const targetElement = [item?.div!]; + selection.targets = targetElement; + settings.scene.select(selection); + } } catch (error) { appEvents.emit(AppEvents.alertError, ['Unable to select element, try selecting element in panel instead']); } @@ -84,6 +101,79 @@ export class LayerElementListEditor extends PureComponent { layer.reorder(src, dst); }; + goUpLayer = () => { + const settings = this.props.item.settings; + + if (!settings?.layer || !settings?.scene) { + return; + } + + const { scene, layer } = settings; + + if (layer.parent) { + scene.updateCurrentLayer(layer.parent); + } + }; + + private decoupleGroup = () => { + const settings = this.props.item.settings; + + if (!settings?.layer) { + return; + } + + const { layer } = settings; + + layer.elements.forEach((element: ElementState) => { + layer.parent?.doAction(LayerActionID.Duplicate, element); + }); + this.deleteGroup(); + }; + + private onDecoupleGroup = () => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Decouple group', + text: `Are you sure you want to decouple this group?`, + text2: 'This will remove the group and push nested elements in the next level up.', + confirmText: 'Yes', + yesText: 'Decouple', + onConfirm: async () => { + this.decoupleGroup(); + }, + }) + ); + }; + + private deleteGroup = () => { + const settings = this.props.item.settings; + + if (!settings?.layer) { + return; + } + + const { layer } = settings; + + layer.parent?.doAction(LayerActionID.Delete, layer); + this.goUpLayer(); + }; + + private onDeleteGroup = () => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Delete group', + text: `Are you sure you want to delete this group?`, + text2: 'This will delete the group and all nested elements.', + icon: 'trash-alt', + confirmText: 'Delete', + yesText: 'Delete', + onConfirm: async () => { + this.deleteGroup(); + }, + }) + ); + }; + render() { const settings = this.props.item.settings; if (!settings) { @@ -98,6 +188,22 @@ export class LayerElementListEditor extends PureComponent { const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : []; return ( <> + {!layer.isRoot() && ( + <> + + + + + + )} {(provided, snapshot) => ( @@ -122,27 +228,31 @@ export class LayerElementListEditor extends PureComponent {   {element.UID} ({i}) - layer.doAction(LayerActionID.Duplicate, element)} - surface="header" - /> + {element.item.id !== 'group' && ( + <> + layer.doAction(LayerActionID.Duplicate, element)} + surface="header" + /> - layer.doAction(LayerActionID.Delete, element)} - surface="header" - /> - + layer.doAction(LayerActionID.Delete, element)} + surface="header" + /> + + + )} )} diff --git a/public/app/plugins/panel/canvas/editor/MultiSelectionEditor.tsx b/public/app/plugins/panel/canvas/editor/MultiSelectionEditor.tsx new file mode 100644 index 00000000000..e105f592dce --- /dev/null +++ b/public/app/plugins/panel/canvas/editor/MultiSelectionEditor.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react'; +import { Button } from '@grafana/ui'; +import { StandardEditorProps } from '@grafana/data'; + +import { InstanceState } from '../CanvasPanel'; +import { PanelOptions } from '../models.gen'; +import { GroupState } from 'app/features/canvas/runtime/group'; +import { ElementState } from 'app/features/canvas/runtime/element'; +import { LayerActionID } from '../types'; + +export const MultiSelectionEditor: FC> = ({ context }) => { + const createNewLayer = () => { + const currentSelectedElements = context?.instanceState.selected; + const currentLayer = currentSelectedElements[0].parent; + + const newLayer = new GroupState( + { + type: 'group', + elements: [], + }, + context.instanceState.scene, + currentSelectedElements[0].parent + ); + + currentSelectedElements.forEach((element: ElementState) => { + newLayer.doAction(LayerActionID.Duplicate, element); + currentLayer.doAction(LayerActionID.Delete, element); + }); + + currentLayer.elements.push(newLayer); + + context.instanceState.scene.save(); + }; + + return ( +
+ +
+ ); +}; diff --git a/public/app/plugins/panel/canvas/editor/elementEditor.tsx b/public/app/plugins/panel/canvas/editor/elementEditor.tsx index 0d8469ad9ba..d8480eebd38 100644 --- a/public/app/plugins/panel/canvas/editor/elementEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/elementEditor.tsx @@ -72,7 +72,7 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions< } const ctx = { ...context, options: currentOptions }; - if (layer.registerOptionsUI) { + if (layer?.registerOptionsUI) { layer.registerOptionsUI(builder, ctx); } diff --git a/public/app/plugins/panel/canvas/editor/elementsEditor.tsx b/public/app/plugins/panel/canvas/editor/elementsEditor.tsx new file mode 100644 index 00000000000..f1fc2d87782 --- /dev/null +++ b/public/app/plugins/panel/canvas/editor/elementsEditor.tsx @@ -0,0 +1,24 @@ +import { NestedPanelOptions } from '@grafana/data/src/utils/OptionsUIBuilders'; +import { Scene } from 'app/features/canvas/runtime/scene'; +import { MultiSelectionEditor } from './MultiSelectionEditor'; + +export interface CanvasEditorGroupOptions { + scene: Scene; + category?: string[]; +} + +export const getElementsEditor = (opts: CanvasEditorGroupOptions): NestedPanelOptions => { + return { + category: opts.category, + path: '--', + build: (builder, context) => { + builder.addCustomEditor({ + id: 'content', + path: '__', // not used + name: 'Options', + editor: MultiSelectionEditor, + settings: opts, + }); + }, + }; +}; diff --git a/public/app/plugins/panel/canvas/editor/layerEditor.tsx b/public/app/plugins/panel/canvas/editor/layerEditor.tsx index 68a1329f2da..ccc3bb9ce5a 100644 --- a/public/app/plugins/panel/canvas/editor/layerEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/layerEditor.tsx @@ -4,10 +4,38 @@ import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/O import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; import { InstanceState } from '../CanvasPanel'; import { LayerElementListEditor } from './LayerElementListEditor'; +import { GroupState } from 'app/features/canvas/runtime/group'; +import { Scene } from 'app/features/canvas/runtime/scene'; +import { ElementState } from 'app/features/canvas/runtime/element'; -export function getLayerEditor(opts: InstanceState): NestedPanelOptions { - const { layer } = opts; - const options = layer.options || { elements: [] }; +export interface LayerEditorProps { + scene: Scene; + layer: GroupState; + selected: ElementState[]; +} + +export function getLayerEditor(opts: InstanceState): NestedPanelOptions { + const { selected, scene } = opts; + + if (!scene.currentLayer) { + scene.currentLayer = scene.root as GroupState; + } + + if (selected) { + for (const element of selected) { + if (element instanceof GroupState) { + scene.currentLayer = element; + break; + } + + if (element.parent) { + scene.currentLayer = element.parent; + break; + } + } + } + + const options = scene.currentLayer.options || { elements: [] }; return { category: ['Layer'], @@ -24,7 +52,7 @@ export function getLayerEditor(opts: InstanceState): NestedPanelOptions(CanvasPanel) .setNoPadding() // extend to panel edges @@ -28,6 +29,13 @@ export const plugin = new PanelPlugin(CanvasPanel) scene: state.scene, }) ); + } else if (selection?.length > 1) { + builder.addNestedOptions( + getElementsEditor({ + category: [`Current selection`], + scene: state.scene, + }) + ); } builder.addNestedOptions(getLayerEditor(state));