mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: add multi layer functionality (#41326)
This commit is contained in:
parent
ea583e7d42
commit
defbd72bf2
@ -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<HTMLElement | SVGElement>;
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -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<PanelOptions> {}
|
||||
|
||||
@ -18,7 +17,6 @@ interface State {
|
||||
export interface InstanceState {
|
||||
scene: Scene;
|
||||
selected: ElementState[];
|
||||
layer: GroupState;
|
||||
}
|
||||
|
||||
export class CanvasPanel extends Component<Props, State> {
|
||||
|
@ -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<any, InstanceState, PanelOptions>;
|
||||
type Props = StandardEditorProps<any, LayerEditorProps, PanelOptions>;
|
||||
|
||||
export class LayerElementListEditor extends PureComponent<Props> {
|
||||
style = getLayerDragStyles(config.theme);
|
||||
@ -40,9 +43,23 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
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<Props> {
|
||||
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<Props> {
|
||||
const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : [];
|
||||
return (
|
||||
<>
|
||||
{!layer.isRoot() && (
|
||||
<>
|
||||
<Button icon="angle-up" size="sm" variant="secondary" onClick={this.goUpLayer}>
|
||||
Go Up Level
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => this.onSelect(layer)}>
|
||||
Select Group
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => this.onDecoupleGroup()}>
|
||||
Decouple Group
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => this.onDeleteGroup()}>
|
||||
Delete Group
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
@ -122,27 +228,31 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
{element.UID} ({i})
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
name="copy"
|
||||
title={'Duplicate'}
|
||||
className={styles.actionIcon}
|
||||
onClick={() => layer.doAction(LayerActionID.Duplicate, element)}
|
||||
surface="header"
|
||||
/>
|
||||
{element.item.id !== 'group' && (
|
||||
<>
|
||||
<IconButton
|
||||
name="copy"
|
||||
title={'Duplicate'}
|
||||
className={styles.actionIcon}
|
||||
onClick={() => layer.doAction(LayerActionID.Duplicate, element)}
|
||||
surface="header"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'Remove'}
|
||||
className={cx(styles.actionIcon, styles.dragIcon)}
|
||||
onClick={() => layer.doAction(LayerActionID.Delete, element)}
|
||||
surface="header"
|
||||
/>
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'Remove'}
|
||||
className={cx(styles.actionIcon, styles.dragIcon)}
|
||||
onClick={() => layer.doAction(LayerActionID.Delete, element)}
|
||||
surface="header"
|
||||
/>
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
|
@ -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<StandardEditorProps<any, InstanceState, PanelOptions>> = ({ 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 (
|
||||
<div>
|
||||
<Button icon="plus" size="sm" variant="secondary" onClick={createNewLayer}>
|
||||
Group items
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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);
|
||||
}
|
||||
|
||||
|
24
public/app/plugins/panel/canvas/editor/elementsEditor.tsx
Normal file
24
public/app/plugins/panel/canvas/editor/elementsEditor.tsx
Normal file
@ -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<any> => {
|
||||
return {
|
||||
category: opts.category,
|
||||
path: '--',
|
||||
build: (builder, context) => {
|
||||
builder.addCustomEditor({
|
||||
id: 'content',
|
||||
path: '__', // not used
|
||||
name: 'Options',
|
||||
editor: MultiSelectionEditor,
|
||||
settings: opts,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
@ -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<InstanceState> {
|
||||
const { layer } = opts;
|
||||
const options = layer.options || { elements: [] };
|
||||
export interface LayerEditorProps {
|
||||
scene: Scene;
|
||||
layer: GroupState;
|
||||
selected: ElementState[];
|
||||
}
|
||||
|
||||
export function getLayerEditor(opts: InstanceState): NestedPanelOptions<LayerEditorProps> {
|
||||
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<Instance
|
||||
return;
|
||||
}
|
||||
const c = setOptionImmutably(options, path, value);
|
||||
layer.onChange(c);
|
||||
scene.currentLayer?.onChange(c);
|
||||
},
|
||||
}),
|
||||
|
||||
@ -35,7 +63,7 @@ export function getLayerEditor(opts: InstanceState): NestedPanelOptions<Instance
|
||||
path: 'root',
|
||||
name: 'Elements',
|
||||
editor: LayerElementListEditor,
|
||||
settings: opts,
|
||||
settings: { scene, layer: scene.currentLayer, selected },
|
||||
});
|
||||
|
||||
// // force clean layer configuration
|
||||
|
@ -4,6 +4,7 @@ import { CanvasPanel, InstanceState } from './CanvasPanel';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { getElementEditor } from './editor/elementEditor';
|
||||
import { getLayerEditor } from './editor/layerEditor';
|
||||
import { getElementsEditor } from './editor/elementsEditor';
|
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
.setNoPadding() // extend to panel edges
|
||||
@ -28,6 +29,13 @@ export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
} else if (selection?.length > 1) {
|
||||
builder.addNestedOptions(
|
||||
getElementsEditor({
|
||||
category: [`Current selection`],
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
builder.addNestedOptions(getLayerEditor(state));
|
||||
|
Loading…
Reference in New Issue
Block a user