diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx
index b74bfa33543..2b367461356 100644
--- a/public/app/features/canvas/runtime/element.tsx
+++ b/public/app/features/canvas/runtime/element.tsx
@@ -6,12 +6,14 @@ import {
CanvasElementItem,
CanvasElementOptions,
canvasElementRegistry,
+ Placement,
+ Anchor,
} from 'app/features/canvas';
import { DimensionContext } from 'app/features/dimensions';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { GroupState } from './group';
-let counter = 100;
+let counter = 0;
export class ElementState {
readonly UID = counter++;
@@ -28,16 +30,82 @@ export class ElementState {
height = 100;
data?: any; // depends on the type
+ // From options, but always set and always valid
+ anchor: Anchor;
+ placement: Placement;
+
constructor(public item: CanvasElementItem, public options: CanvasElementOptions, public parent?: GroupState) {
if (!options) {
this.options = { type: item.id };
}
+ this.anchor = options.anchor ?? {};
+ this.placement = options.placement ?? {};
+ options.anchor = this.anchor;
+ options.placement = this.placement;
+ }
+
+ validatePlacement() {
+ const { anchor, placement } = this;
+ if (!(anchor.left || anchor.right)) {
+ anchor.left = true;
+ }
+ if (!(anchor.top || anchor.bottom)) {
+ anchor.top = true;
+ }
+
+ const w = placement.width ?? 100; // this.div ? this.div.clientWidth : this.width;
+ const h = placement.height ?? 100; // this.div ? this.div.clientHeight : this.height;
+
+ if (anchor.top) {
+ if (!placement.top) {
+ placement.top = 0;
+ }
+ if (anchor.bottom) {
+ delete placement.height;
+ } else {
+ placement.height = h;
+ delete placement.bottom;
+ }
+ } else if (anchor.bottom) {
+ if (!placement.bottom) {
+ placement.bottom = 0;
+ }
+ placement.height = h;
+ delete placement.top;
+ }
+
+ if (anchor.left) {
+ if (!placement.left) {
+ placement.left = 0;
+ }
+ if (anchor.right) {
+ delete placement.width;
+ } else {
+ placement.width = w;
+ delete placement.right;
+ }
+ } else if (anchor.right) {
+ if (!placement.right) {
+ placement.right = 0;
+ }
+ placement.width = w;
+ delete placement.left;
+ }
+
+ this.width = w;
+ this.height = h;
+
+ this.options.anchor = this.anchor;
+ this.options.placement = this.placement;
+
+ // console.log('validate', this.UID, this.item.id, this.placement, this.anchor);
}
// The parent size, need to set our own size based on offsets
updateSize(width: number, height: number) {
this.width = width;
this.height = height;
+ this.validatePlacement();
// Update the CSS position
this.sizeStyle = {
@@ -129,33 +197,95 @@ export class ElementState {
initElement = (target: HTMLDivElement) => {
this.div = target;
-
- let placement = this.options.placement;
- if (!placement) {
- placement = {
- left: 0,
- top: 0,
- };
- this.options.placement = placement;
- }
};
applyDrag = (event: OnDrag) => {
- const placement = this.options.placement;
- placement!.top = event.top;
- placement!.left = event.left;
+ const { placement, anchor } = this;
- event.target.style.top = `${event.top}px`;
- event.target.style.left = `${event.left}px`;
+ const deltaX = event.delta[0];
+ const deltaY = event.delta[1];
+
+ const style = event.target.style;
+ if (anchor.top) {
+ placement.top! += deltaY;
+ style.top = `${placement.top}px`;
+ }
+ if (anchor.bottom) {
+ placement.bottom! -= deltaY;
+ style.bottom = `${placement.bottom}px`;
+ }
+ if (anchor.left) {
+ placement.left! += deltaX;
+ style.left = `${placement.left}px`;
+ }
+ if (anchor.right) {
+ placement.right! -= deltaX;
+ style.right = `${placement.right}px`;
+ }
};
+ // kinda like:
+ // https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44
applyResize = (event: OnResize) => {
- const placement = this.options.placement;
- placement!.height = event.height;
- placement!.width = event.width;
+ const { placement, anchor } = this;
- event.target.style.height = `${event.height}px`;
- event.target.style.width = `${event.width}px`;
+ const style = event.target.style;
+ const deltaX = event.delta[0];
+ const deltaY = event.delta[1];
+ const dirLR = event.direction[0];
+ const dirTB = event.direction[1];
+ if (dirLR === 1) {
+ // RIGHT
+ if (anchor.right) {
+ placement.right! -= deltaX;
+ style.right = `${placement.right}px`;
+ if (!anchor.left) {
+ placement.width = event.width;
+ style.width = `${placement.width}px`;
+ }
+ } else {
+ placement.width! = event.width;
+ style.width = `${placement.width}px`;
+ }
+ } else if (dirLR === -1) {
+ // LEFT
+ if (anchor.left) {
+ placement.left! -= deltaX;
+ placement.width! = event.width;
+ style.left = `${placement.left}px`;
+ style.width = `${placement.width}px`;
+ } else {
+ placement.width! += deltaX;
+ style.width = `${placement.width}px`;
+ }
+ }
+
+ if (dirTB === -1) {
+ // TOP
+ if (anchor.top) {
+ placement.top! -= deltaY;
+ placement.height = event.height;
+ style.top = `${placement.top}px`;
+ style.height = `${placement.height}px`;
+ } else {
+ placement.height = event.height;
+ style.height = `${placement.height}px`;
+ }
+ } else if (dirTB === 1) {
+ // BOTTOM
+ if (anchor.bottom) {
+ placement.bottom! -= deltaY;
+ placement.height! = event.height;
+ style.bottom = `${placement.bottom}px`;
+ style.height = `${placement.height}px`;
+ } else {
+ placement.height! = event.height;
+ style.height = `${placement.height}px`;
+ }
+ }
+
+ this.width = event.width;
+ this.height = event.height;
};
render() {
diff --git a/public/app/features/canvas/runtime/group.tsx b/public/app/features/canvas/runtime/group.tsx
index 6758d59d78d..e7ff5c7d738 100644
--- a/public/app/features/canvas/runtime/group.tsx
+++ b/public/app/features/canvas/runtime/group.tsx
@@ -4,6 +4,8 @@ import { DimensionContext } from 'app/features/dimensions';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { ElementState } from './element';
import { CanvasElementItem } from '../element';
+import { LayerActionID } from 'app/plugins/panel/canvas/types';
+import { cloneDeep } from 'lodash';
export const groupItemDummy: CanvasElementItem = {
id: 'group',
@@ -19,7 +21,7 @@ export const groupItemDummy: CanvasElementItem = {
};
export class GroupState extends ElementState {
- readonly elements: ElementState[] = [];
+ elements: ElementState[] = [];
constructor(public options: CanvasGroupOptions, public parent?: GroupState) {
super(groupItemDummy, options, parent);
@@ -35,14 +37,24 @@ export class GroupState extends ElementState {
this.elements.push(new GroupState(c as CanvasGroupOptions, this));
} else {
const item = canvasElementRegistry.getIfExists(c.type) ?? notFoundItem;
- this.elements.push(new ElementState(item, c, parent));
+ this.elements.push(new ElementState(item, c, this));
}
}
}
+ isRoot() {
+ return false;
+ }
+
// The parent size, need to set our own size based on offsets
updateSize(width: number, height: number) {
super.updateSize(width, height);
+ if (!this.parent) {
+ this.width = width;
+ this.height = height;
+ this.sizeStyle.width = width;
+ this.sizeStyle.height = height;
+ }
// Update children with calculated size
for (const elem of this.elements) {
@@ -62,6 +74,54 @@ export class GroupState extends ElementState {
}
}
+ // used in the layer editor
+ reorder(startIndex: number, endIndex: number) {
+ const result = Array.from(this.elements);
+ const [removed] = result.splice(startIndex, 1);
+ result.splice(endIndex, 0, removed);
+ this.elements = result;
+ this.onChange(this.getSaveModel());
+ }
+
+ // ??? or should this be on the element directly?
+ // are actions scoped to layers?
+ doAction = (action: LayerActionID, element: ElementState) => {
+ switch (action) {
+ case LayerActionID.Delete:
+ this.elements = this.elements.filter((e) => e !== element);
+ break;
+ case LayerActionID.Duplicate:
+ if (element.item.id === 'group') {
+ console.log('Can not duplicate groups (yet)', action, element);
+ return;
+ }
+ const opts = cloneDeep(element.options);
+ if (element.anchor.top) {
+ opts.placement!.top! += 10;
+ }
+ if (element.anchor.left) {
+ opts.placement!.left! += 10;
+ }
+ if (element.anchor.bottom) {
+ opts.placement!.bottom! += 10;
+ }
+ if (element.anchor.right) {
+ opts.placement!.right! += 10;
+ }
+ console.log('DUPLICATE', opts);
+ const copy = new ElementState(element.item, opts, this);
+ copy.updateSize(element.width, element.height);
+ copy.updateData(element.data); // :bomb: <-- need some way to tell the scene to re-init size and data
+ this.elements.push(copy);
+ break;
+ default:
+ console.log('DO action', action, element);
+ return;
+ }
+
+ this.onChange(this.getSaveModel());
+ };
+
render() {
return (
diff --git a/public/app/features/canvas/runtime/root.tsx b/public/app/features/canvas/runtime/root.tsx
new file mode 100644
index 00000000000..bac61ccd77d
--- /dev/null
+++ b/public/app/features/canvas/runtime/root.tsx
@@ -0,0 +1,37 @@
+import { CanvasGroupOptions, CanvasElementOptions } from 'app/features/canvas';
+import { GroupState } from './group';
+
+export class RootElement extends GroupState {
+ constructor(public options: CanvasGroupOptions, private changeCallback: () => void) {
+ super(options);
+ }
+
+ isRoot() {
+ return true;
+ }
+
+ // The parent size is always fullsize
+ updateSize(width: number, height: number) {
+ super.updateSize(width, height);
+ this.width = width;
+ this.height = height;
+ this.sizeStyle.width = width;
+ this.sizeStyle.height = height;
+ }
+
+ // root type can not change
+ onChange(options: CanvasElementOptions) {
+ this.revId++;
+ this.options = { ...options } as CanvasGroupOptions;
+ this.changeCallback();
+ }
+
+ getSaveModel() {
+ const { placement, anchor, ...rest } = this.options;
+
+ return {
+ ...rest, // everything except placement & anchor
+ elements: this.elements.map((v) => v.getSaveModel()),
+ } as CanvasGroupOptions;
+ }
+}
diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx
index 72ddc84bba4..ae30613d838 100644
--- a/public/app/features/canvas/runtime/scene.tsx
+++ b/public/app/features/canvas/runtime/scene.tsx
@@ -1,13 +1,19 @@
import React, { CSSProperties } from 'react';
import { css } from '@emotion/css';
-import { ReplaySubject } from 'rxjs';
+import { ReplaySubject, Subject } from 'rxjs';
import Moveable from 'moveable';
import Selecto from 'selecto';
import { config } from 'app/core/config';
import { GrafanaTheme2, PanelData } from '@grafana/data';
import { stylesFactory } from '@grafana/ui';
-import { CanvasElementOptions, CanvasGroupOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
+import {
+ Anchor,
+ CanvasElementOptions,
+ CanvasGroupOptions,
+ DEFAULT_CANVAS_ELEMENT_CONFIG,
+ Placement,
+} from 'app/features/canvas';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
@@ -21,20 +27,23 @@ import {
getResourceDimensionFromData,
getTextDimensionFromData,
} from 'app/features/dimensions/utils';
-import { GroupState } from './group';
import { ElementState } from './element';
+import { RootElement } from './root';
export class Scene {
- private root: GroupState;
private lookup = new Map
();
styles = getStyles(config.theme2);
- readonly selected = new ReplaySubject(undefined);
+ readonly selection = new ReplaySubject(1);
+ readonly moved = new Subject(); // called after resize/drag for editor updates
+ root: RootElement;
+
revId = 0;
width = 0;
height = 0;
style: CSSProperties = {};
data?: PanelData;
+ selecto?: Selecto | null;
constructor(cfg: CanvasGroupOptions, public onSave: (cfg: CanvasGroupOptions) => void) {
this.root = this.load(cfg);
@@ -42,23 +51,20 @@ export class Scene {
load(cfg: CanvasGroupOptions) {
console.log('LOAD', cfg, this);
- this.root = new GroupState(
+ this.root = new RootElement(
cfg ?? {
type: 'group',
elements: [DEFAULT_CANVAS_ELEMENT_CONFIG],
- }
+ },
+ this.save // callback when changes are made
);
// Build the scene registry
this.lookup.clear();
this.root.visit((v) => {
this.lookup.set(v.UID, v);
-
- // HACK! select the first/only item
- if (v.item.id !== 'group') {
- this.selected.next(v);
- }
});
+
return this.root;
}
@@ -92,10 +98,47 @@ export class Scene {
this.save();
}
- save() {
- this.onSave(this.root.getSaveModel());
+ toggleAnchor(element: ElementState, k: keyof Anchor) {
+ console.log('TODO, smarter toggle', element.UID, element.anchor, k);
+ const { div } = element;
+ if (!div) {
+ console.log('Not ready');
+ return;
+ }
+
+ const w = element.parent?.width ?? 100;
+ const h = element.parent?.height ?? 100;
+
+ // Get computed position....
+ const info = div.getBoundingClientRect(); // getElementInfo(div, element.parent?.div);
+ console.log('DIV info', div);
+
+ const placement: Placement = {
+ top: info.top,
+ left: info.left,
+ width: info.width,
+ height: info.height,
+ bottom: h - info.bottom,
+ right: w - info.right,
+ };
+
+ console.log('PPP', placement);
+
+ // // TODO: needs to recalculate placement based on absolute values...
+ // element.anchor[k] = !Boolean(element.anchor[k]);
+ // element.placement = placement;
+ // element.validatePlacement();
+ // element.revId++;
+ // this.revId++;
+ // this.save();
+
+ this.moved.next(Date.now());
}
+ save = () => {
+ this.onSave(this.root.getSaveModel());
+ };
+
private findElementByTarget = (target: HTMLElement | SVGElement): ElementState | undefined => {
return this.root.elements.find((element) => element.div === target);
};
@@ -106,9 +149,10 @@ export class Scene {
targetElements.push(element.div!);
});
- const selecto = new Selecto({
+ this.selecto = new Selecto({
container: sceneContainer,
selectableTargets: targetElements,
+ selectByClick: true,
});
const moveable = new Moveable(sceneContainer, {
@@ -116,63 +160,67 @@ export class Scene {
resizable: true,
})
.on('clickGroup', (event) => {
- selecto.clickTarget(event.inputEvent, event.inputTarget);
+ this.selecto!.clickTarget(event.inputEvent, event.inputTarget);
})
.on('drag', (event) => {
const targetedElement = this.findElementByTarget(event.target);
targetedElement!.applyDrag(event);
+ this.moved.next(Date.now()); // TODO only on end
})
.on('dragGroup', (e) => {
e.events.forEach((event) => {
const targetedElement = this.findElementByTarget(event.target);
targetedElement!.applyDrag(event);
});
+ this.moved.next(Date.now()); // TODO only on end
})
.on('resize', (event) => {
const targetedElement = this.findElementByTarget(event.target);
targetedElement!.applyResize(event);
+ this.moved.next(Date.now()); // TODO only on end
})
.on('resizeGroup', (e) => {
e.events.forEach((event) => {
const targetedElement = this.findElementByTarget(event.target);
targetedElement!.applyResize(event);
});
+ this.moved.next(Date.now()); // TODO only on end
});
let targets: Array = [];
- selecto
- .on('dragStart', (event) => {
- const selectedTarget = event.inputEvent.target;
+ this.selecto!.on('dragStart', (event) => {
+ const selectedTarget = event.inputEvent.target;
- const isTargetMoveableElement =
- moveable.isMoveableElement(selectedTarget) ||
- targets.some((target) => target === selectedTarget || target.contains(selectedTarget));
+ const isTargetMoveableElement =
+ moveable.isMoveableElement(selectedTarget) ||
+ targets.some((target) => target === selectedTarget || target.contains(selectedTarget));
- if (isTargetMoveableElement) {
- // Prevent drawing selection box when selected target is a moveable element
- event.stop();
- }
- })
- .on('selectEnd', (event) => {
- targets = event.selected;
- moveable.target = targets;
+ if (isTargetMoveableElement) {
+ // Prevent drawing selection box when selected target is a moveable element
+ event.stop();
+ }
+ }).on('selectEnd', (event) => {
+ targets = event.selected;
+ moveable.target = targets;
- if (event.isDragStart) {
- event.inputEvent.preventDefault();
+ const s = event.selected.map((t) => this.findElementByTarget(t)!);
+ this.selection.next(s);
+ console.log('UPDATE selection', s);
- setTimeout(() => {
- moveable.dragStart(event.inputEvent);
- });
- }
- });
+ if (event.isDragStart) {
+ event.inputEvent.preventDefault();
+
+ setTimeout(() => {
+ moveable.dragStart(event.inputEvent);
+ });
+ }
+ });
};
render() {
return (
-
-
- {this.root.render()}
-
+
+ {this.root.render()}
);
}
diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx
index fe0de16f724..85a942c5b3a 100644
--- a/public/app/plugins/panel/canvas/CanvasPanel.tsx
+++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx
@@ -7,6 +7,7 @@ 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
{}
@@ -16,7 +17,8 @@ interface State {
export interface InstanceState {
scene: Scene;
- selected?: ElementState;
+ selected: ElementState[];
+ layer: GroupState;
}
export class CanvasPanel extends Component {
@@ -53,14 +55,16 @@ export class CanvasPanel extends Component {
if (this.panelContext.onInstanceStateChange && this.panelContext.app === CoreApp.PanelEditor) {
this.panelContext.onInstanceStateChange({
scene: this.scene,
+ layer: this.scene.root,
});
this.subs.add(
- this.scene.selected.subscribe({
+ this.scene.selection.subscribe({
next: (v) => {
this.panelContext.onInstanceStateChange!({
scene: this.scene,
selected: v,
+ layer: this.scene.root,
});
},
})
diff --git a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx
new file mode 100644
index 00000000000..3a4e6fa7a43
--- /dev/null
+++ b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx
@@ -0,0 +1,206 @@
+import React, { PureComponent } from 'react';
+import { css, cx } from '@emotion/css';
+import { Button, Container, Icon, IconButton, stylesFactory, ValuePicker } from '@grafana/ui';
+import { GrafanaTheme, SelectableValue, StandardEditorProps } from '@grafana/data';
+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 { canvasElementRegistry } from 'app/features/canvas';
+
+type Props = StandardEditorProps;
+
+export class LayerElementListEditor extends PureComponent {
+ style = getStyles(config.theme);
+
+ onAddItem = (sel: SelectableValue) => {
+ // const reg = drawItemsRegistry.getIfExists(sel.value);
+ // if (!reg) {
+ // console.error('NOT FOUND', sel);
+ // return;
+ // }
+ // const layer = this.props.value;
+ // const item = newItem(reg, layer.items.length);
+ // const isList = this.props.context.options?.mode === LayoutMode.List;
+ // const items = isList ? [item, ...layer.items] : [...layer.items, item];
+ // this.props.onChange({
+ // ...layer,
+ // items,
+ // });
+ // this.onSelect(item);
+ };
+
+ onSelect = (item: any) => {
+ const { settings } = this.props.item;
+
+ if (settings?.scene && settings?.scene?.selecto) {
+ settings.scene.selecto.clickTarget(item, item?.div);
+ }
+ };
+
+ getRowStyle = (sel: boolean) => {
+ return sel ? `${this.style.row} ${this.style.sel}` : this.style.row;
+ };
+
+ onDragEnd = (result: DropResult) => {
+ if (!result.destination) {
+ return;
+ }
+
+ const { settings } = this.props.item;
+ if (!settings?.layer) {
+ return;
+ }
+
+ const { layer } = settings;
+
+ const count = layer.elements.length - 1;
+ const src = (result.source.index - count) * -1;
+ const dst = (result.destination.index - count) * -1;
+
+ layer.reorder(src, dst);
+ };
+
+ render() {
+ const settings = this.props.item.settings;
+ if (!settings) {
+ return No settings
;
+ }
+ const layer = settings.layer;
+ if (!layer) {
+ return Missing layer?
;
+ }
+
+ const styles = this.style;
+ const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : [];
+ return (
+ <>
+
+
+ {(provided, snapshot) => (
+
+ {(() => {
+ // reverse order
+ const rows: any = [];
+ for (let i = layer.elements.length - 1; i >= 0; i--) {
+ const element = layer.elements[i];
+ rows.push(
+
+ {(provided, snapshot) => (
+ this.onSelect(element)}
+ >
+
{element.item.name}
+
+ {element.UID} ({i})
+
+
+
layer.doAction(LayerActionID.Duplicate, element)}
+ surface="header"
+ />
+
+ layer.doAction(LayerActionID.Delete, element)}
+ surface="header"
+ />
+
+
+ )}
+
+ );
+ }
+ return rows;
+ })()}
+
+ {provided.placeholder}
+
+ )}
+
+
+
+
+
+
+ {selection.length > 0 && (
+
+ )}
+
+ >
+ );
+ }
+}
+
+const getStyles = stylesFactory((theme: GrafanaTheme) => ({
+ wrapper: css`
+ margin-bottom: ${theme.spacing.md};
+ `,
+ row: css`
+ padding: ${theme.spacing.xs} ${theme.spacing.sm};
+ border-radius: ${theme.border.radius.sm};
+ background: ${theme.colors.bg2};
+ min-height: ${theme.spacing.formInputHeight}px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 3px;
+ cursor: pointer;
+
+ border: 1px solid ${theme.colors.formInputBorder};
+ &:hover {
+ border: 1px solid ${theme.colors.formInputBorderHover};
+ }
+ `,
+ sel: css`
+ border: 1px solid ${theme.colors.formInputBorderActive};
+ &:hover {
+ border: 1px solid ${theme.colors.formInputBorderActive};
+ }
+ `,
+ dragIcon: css`
+ cursor: drag;
+ `,
+ actionIcon: css`
+ color: ${theme.colors.textWeak};
+ &:hover {
+ color: ${theme.colors.text};
+ }
+ `,
+ typeWrapper: css`
+ color: ${theme.colors.textBlue};
+ margin-right: 5px;
+ `,
+ textWrapper: css`
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+ overflow: hidden;
+ margin-right: ${theme.spacing.sm};
+ `,
+}));
diff --git a/public/app/plugins/panel/canvas/editor/PlacementEditor.tsx b/public/app/plugins/panel/canvas/editor/PlacementEditor.tsx
new file mode 100644
index 00000000000..6e0c4c7e9c3
--- /dev/null
+++ b/public/app/plugins/panel/canvas/editor/PlacementEditor.tsx
@@ -0,0 +1,66 @@
+import React, { FC } from 'react';
+import { Button, Field, HorizontalGroup, InlineField, InlineFieldRow } from '@grafana/ui';
+import { StandardEditorProps } from '@grafana/data';
+
+import { PanelOptions } from '../models.gen';
+import { useObservable } from 'react-use';
+import { Subject } from 'rxjs';
+import { CanvasEditorOptions } from './elementEditor';
+import { Anchor, Placement } from 'app/features/canvas';
+import { NumberInput } from 'app/features/dimensions/editors/NumberInput';
+
+const anchors: Array = ['top', 'left', 'bottom', 'right'];
+const places: Array = ['top', 'left', 'bottom', 'right', 'width', 'height'];
+
+export const PlacementEditor: FC> = ({ item }) => {
+ const settings = item.settings;
+
+ // Will force a rerender whenever the subject changes
+ useObservable(settings?.scene ? settings.scene.moved : new Subject());
+
+ if (!settings) {
+ return Loading...
;
+ }
+
+ const element = settings.element;
+ if (!element) {
+ return ???
;
+ }
+ const { placement } = element;
+
+ return (
+
+
+ {anchors.map((a) => (
+
+ ))}
+
+
+
+
+ <>
+ {places.map((p) => {
+ const v = placement[p];
+ if (v == null) {
+ return null;
+ }
+ return (
+
+
+ console.log('TODO, edit!!!', p, v)} />
+
+
+ );
+ })}
+ >
+
+
+ );
+};
diff --git a/public/app/plugins/panel/canvas/editor/elementEditor.tsx b/public/app/plugins/panel/canvas/editor/elementEditor.tsx
index 67b036da8c7..9ba38ec3167 100644
--- a/public/app/plugins/panel/canvas/editor/elementEditor.tsx
+++ b/public/app/plugins/panel/canvas/editor/elementEditor.tsx
@@ -5,6 +5,7 @@ import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/O
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
import { ElementState } from 'app/features/canvas/runtime/element';
import { Scene } from 'app/features/canvas/runtime/scene';
+import { PlacementEditor } from './PlacementEditor';
export interface CanvasEditorOptions {
element: ElementState;
@@ -44,6 +45,8 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
// Dynamically fill the selected element
build: (builder, context) => {
+ console.log('MAKE element editor', opts.element.UID);
+
const { options } = opts.element;
const layerTypes = canvasElementRegistry.selectOptions(
options?.type // the selected value
@@ -70,6 +73,15 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
optionBuilder.addBackground(builder, ctx);
optionBuilder.addBorder(builder, ctx);
+
+ builder.addCustomEditor({
+ category: ['Layout'],
+ id: 'content',
+ path: '__', // not used
+ name: 'Anchor',
+ editor: PlacementEditor,
+ settings: opts,
+ });
},
};
}
diff --git a/public/app/plugins/panel/canvas/editor/layerEditor.tsx b/public/app/plugins/panel/canvas/editor/layerEditor.tsx
new file mode 100644
index 00000000000..0b311369288
--- /dev/null
+++ b/public/app/plugins/panel/canvas/editor/layerEditor.tsx
@@ -0,0 +1,56 @@
+import { get as lodashGet } from 'lodash';
+import { optionBuilder } from './options';
+import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
+import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
+import { InstanceState } from '../CanvasPanel';
+import { LayerElementListEditor } from './LayerElementListEditor';
+
+export function getLayerEditor(opts: InstanceState): NestedPanelOptions {
+ const { layer, scene } = opts;
+ const options = layer.options || { elements: [] };
+
+ return {
+ category: ['Layer'],
+ path: '--', // not used!
+
+ // Note that canvas editor writes things to the scene!
+ values: (parent: NestedValueAccess) => ({
+ getValue: (path: string) => {
+ return lodashGet(options, path);
+ },
+ onChange: (path: string, value: any) => {
+ if (path === 'type' && value) {
+ console.warn('unable to change layer type');
+ return;
+ }
+ const c = setOptionImmutably(options, path, value);
+ scene.onChange(layer.UID, c);
+ },
+ }),
+
+ // Dynamically fill the selected element
+ build: (builder, context) => {
+ console.log('MAKE layer editor', layer.UID);
+
+ builder.addCustomEditor({
+ id: 'content',
+ path: 'root',
+ name: 'Elements',
+ editor: LayerElementListEditor,
+ settings: opts,
+ });
+
+ // // force clean layer configuration
+ // const layer = canvasElementRegistry.getIfExists(options?.type ?? DEFAULT_CANVAS_ELEMENT_CONFIG.type)!;
+ //const currentOptions = { ...options, type: layer.id, config: { ...layer.defaultConfig, ...options?.config } };
+ const ctx = { ...context, options };
+
+ // if (layer.registerOptionsUI) {
+ // layer.registerOptionsUI(builder, ctx);
+ // }
+
+ optionBuilder.addBackground(builder as any, ctx);
+ optionBuilder.addBorder(builder as any, ctx);
+ },
+ };
+}
diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx
index 8ea5bffe101..28bb10cb5a1 100644
--- a/public/app/plugins/panel/canvas/module.tsx
+++ b/public/app/plugins/panel/canvas/module.tsx
@@ -3,6 +3,7 @@ import { PanelPlugin } from '@grafana/data';
import { CanvasPanel, InstanceState } from './CanvasPanel';
import { PanelOptions } from './models.gen';
import { getElementEditor } from './editor/elementEditor';
+import { getLayerEditor } from './editor/layerEditor';
export const plugin = new PanelPlugin(CanvasPanel)
.setNoPadding() // extend to panel edges
@@ -17,13 +18,20 @@ export const plugin = new PanelPlugin(CanvasPanel)
defaultValue: true,
});
- if (state?.selected) {
- builder.addNestedOptions(
- getElementEditor({
- category: ['Selected element'],
- element: state.selected,
- scene: state.scene,
- })
- );
+ if (state) {
+ const selection = state.selected;
+ if (selection?.length === 1) {
+ builder.addNestedOptions(
+ getElementEditor({
+ category: [`Selected element (id: ${selection[0].UID})`], // changing the ID forces are reload
+ element: selection[0],
+ scene: state.scene,
+ })
+ );
+ } else {
+ console.log('NO Single seleciton', selection?.length);
+ }
+
+ builder.addNestedOptions(getLayerEditor(state));
}
});
diff --git a/public/app/plugins/panel/canvas/types.ts b/public/app/plugins/panel/canvas/types.ts
new file mode 100644
index 00000000000..20943d4054c
--- /dev/null
+++ b/public/app/plugins/panel/canvas/types.ts
@@ -0,0 +1,6 @@
+export enum LayerActionID {
+ Delete = 'delete',
+ Duplicate = 'duplicate',
+ MoveTop = 'move-top',
+ MoveBottom = 'move-bottom',
+}