From 8de30ccf9a542cd719e47470ef0a76b3724fc2f5 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Wed, 13 Oct 2021 13:12:16 -0700 Subject: [PATCH] Canvas: Add basic responsive design and layer editor UX (#40404) --- .../app/features/canvas/runtime/element.tsx | 170 +++++++++++++-- public/app/features/canvas/runtime/group.tsx | 64 +++++- public/app/features/canvas/runtime/root.tsx | 37 ++++ public/app/features/canvas/runtime/scene.tsx | 130 +++++++---- .../app/plugins/panel/canvas/CanvasPanel.tsx | 8 +- .../canvas/editor/LayerElementListEditor.tsx | 206 ++++++++++++++++++ .../panel/canvas/editor/PlacementEditor.tsx | 66 ++++++ .../panel/canvas/editor/elementEditor.tsx | 12 + .../panel/canvas/editor/layerEditor.tsx | 56 +++++ public/app/plugins/panel/canvas/module.tsx | 24 +- public/app/plugins/panel/canvas/types.ts | 6 + 11 files changed, 706 insertions(+), 73 deletions(-) create mode 100644 public/app/features/canvas/runtime/root.tsx create mode 100644 public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx create mode 100644 public/app/plugins/panel/canvas/editor/PlacementEditor.tsx create mode 100644 public/app/plugins/panel/canvas/editor/layerEditor.tsx create mode 100644 public/app/plugins/panel/canvas/types.ts 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', +}