Canvas: add multi layer functionality (#41326)

This commit is contained in:
Nathan Marrs 2021-11-08 11:05:16 -08:00 committed by GitHub
parent ea583e7d42
commit defbd72bf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 316 additions and 44 deletions

View File

@ -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);
});
}
});

View File

@ -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> {

View File

@ -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> {
&nbsp; {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>

View File

@ -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>
);
};

View File

@ -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);
}

View 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,
});
},
};
};

View File

@ -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

View File

@ -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));