mirror of https://github.com/grafana/grafana.git
				
				
				
			Canvas: Add basic responsive design and layer editor UX (#40404)
This commit is contained in:
		
							parent
							
								
									5af96544f3
								
							
						
					
					
						commit
						8de30ccf9a
					
				|  | @ -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() { | ||||
|  |  | |||
|  | @ -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 ( | ||||
|       <div key={`${this.UID}/${this.revId}`} style={{ ...this.sizeStyle, ...this.dataStyle }}> | ||||
|  |  | |||
|  | @ -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; | ||||
|   } | ||||
| } | ||||
|  | @ -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<number, ElementState>(); | ||||
|   styles = getStyles(config.theme2); | ||||
|   readonly selected = new ReplaySubject<ElementState | undefined>(undefined); | ||||
|   readonly selection = new ReplaySubject<ElementState[]>(1); | ||||
|   readonly moved = new Subject<number>(); // 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<HTMLElement | SVGElement> = []; | ||||
|     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 ( | ||||
|       <div> | ||||
|         <div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.initMoveable}> | ||||
|           {this.root.render()} | ||||
|         </div> | ||||
|       <div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.initMoveable}> | ||||
|         {this.root.render()} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -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<PanelOptions> {} | ||||
| 
 | ||||
|  | @ -16,7 +17,8 @@ interface State { | |||
| 
 | ||||
| export interface InstanceState { | ||||
|   scene: Scene; | ||||
|   selected?: ElementState; | ||||
|   selected: ElementState[]; | ||||
|   layer: GroupState; | ||||
| } | ||||
| 
 | ||||
| export class CanvasPanel extends Component<Props, State> { | ||||
|  | @ -53,14 +55,16 @@ export class CanvasPanel extends Component<Props, State> { | |||
|     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, | ||||
|             }); | ||||
|           }, | ||||
|         }) | ||||
|  |  | |||
|  | @ -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<any, InstanceState, PanelOptions>; | ||||
| 
 | ||||
| export class LayerElementListEditor extends PureComponent<Props> { | ||||
|   style = getStyles(config.theme); | ||||
| 
 | ||||
|   onAddItem = (sel: SelectableValue<string>) => { | ||||
|     // 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 <div>No settings</div>; | ||||
|     } | ||||
|     const layer = settings.layer; | ||||
|     if (!layer) { | ||||
|       return <div>Missing layer?</div>; | ||||
|     } | ||||
| 
 | ||||
|     const styles = this.style; | ||||
|     const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : []; | ||||
|     return ( | ||||
|       <> | ||||
|         <DragDropContext onDragEnd={this.onDragEnd}> | ||||
|           <Droppable droppableId="droppable"> | ||||
|             {(provided, snapshot) => ( | ||||
|               <div {...provided.droppableProps} ref={provided.innerRef}> | ||||
|                 {(() => { | ||||
|                   // reverse order
 | ||||
|                   const rows: any = []; | ||||
|                   for (let i = layer.elements.length - 1; i >= 0; i--) { | ||||
|                     const element = layer.elements[i]; | ||||
|                     rows.push( | ||||
|                       <Draggable key={element.UID} draggableId={`${element.UID}`} index={rows.length}> | ||||
|                         {(provided, snapshot) => ( | ||||
|                           <div | ||||
|                             className={this.getRowStyle(selection.includes(element.UID))} | ||||
|                             ref={provided.innerRef} | ||||
|                             {...provided.draggableProps} | ||||
|                             {...provided.dragHandleProps} | ||||
|                             onMouseDown={() => this.onSelect(element)} | ||||
|                           > | ||||
|                             <span className={styles.typeWrapper}>{element.item.name}</span> | ||||
|                             <div className={styles.textWrapper}> | ||||
|                                 {element.UID} ({i}) | ||||
|                             </div> | ||||
| 
 | ||||
|                             <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} | ||||
|                             /> | ||||
|                           </div> | ||||
|                         )} | ||||
|                       </Draggable> | ||||
|                     ); | ||||
|                   } | ||||
|                   return rows; | ||||
|                 })()} | ||||
| 
 | ||||
|                 {provided.placeholder} | ||||
|               </div> | ||||
|             )} | ||||
|           </Droppable> | ||||
|         </DragDropContext> | ||||
|         <br /> | ||||
| 
 | ||||
|         <Container> | ||||
|           <ValuePicker | ||||
|             icon="plus" | ||||
|             label="Add item" | ||||
|             variant="secondary" | ||||
|             options={canvasElementRegistry.selectOptions().options} | ||||
|             onChange={this.onAddItem} | ||||
|             isFullWidth={false} | ||||
|           /> | ||||
|           {selection.length > 0 && ( | ||||
|             <Button size="sm" variant="secondary" onClick={() => console.log('TODO!')}> | ||||
|               Clear Selection | ||||
|             </Button> | ||||
|           )} | ||||
|         </Container> | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 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}; | ||||
|   `,
 | ||||
| })); | ||||
|  | @ -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<keyof Anchor> = ['top', 'left', 'bottom', 'right']; | ||||
| const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height']; | ||||
| 
 | ||||
| export const PlacementEditor: FC<StandardEditorProps<any, CanvasEditorOptions, PanelOptions>> = ({ item }) => { | ||||
|   const settings = item.settings; | ||||
| 
 | ||||
|   // Will force a rerender whenever the subject changes
 | ||||
|   useObservable(settings?.scene ? settings.scene.moved : new Subject()); | ||||
| 
 | ||||
|   if (!settings) { | ||||
|     return <div>Loading...</div>; | ||||
|   } | ||||
| 
 | ||||
|   const element = settings.element; | ||||
|   if (!element) { | ||||
|     return <div>???</div>; | ||||
|   } | ||||
|   const { placement } = element; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <HorizontalGroup> | ||||
|         {anchors.map((a) => ( | ||||
|           <Button | ||||
|             key={a} | ||||
|             size="sm" | ||||
|             variant={element.anchor[a] ? 'primary' : 'secondary'} | ||||
|             onClick={() => settings.scene.toggleAnchor(element, a)} | ||||
|           > | ||||
|             {a} | ||||
|           </Button> | ||||
|         ))} | ||||
|       </HorizontalGroup> | ||||
|       <br /> | ||||
| 
 | ||||
|       <Field label="Position"> | ||||
|         <> | ||||
|           {places.map((p) => { | ||||
|             const v = placement[p]; | ||||
|             if (v == null) { | ||||
|               return null; | ||||
|             } | ||||
|             return ( | ||||
|               <InlineFieldRow key={p}> | ||||
|                 <InlineField label={p} labelWidth={8} grow={true}> | ||||
|                   <NumberInput value={v} onChange={(v) => console.log('TODO, edit!!!', p, v)} /> | ||||
|                 </InlineField> | ||||
|               </InlineFieldRow> | ||||
|             ); | ||||
|           })} | ||||
|         </> | ||||
|       </Field> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -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, | ||||
|       }); | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -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<InstanceState> { | ||||
|   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); | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
|  | @ -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<PanelOptions>(CanvasPanel) | ||||
|   .setNoPadding() // extend to panel edges
 | ||||
|  | @ -17,13 +18,20 @@ export const plugin = new PanelPlugin<PanelOptions>(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)); | ||||
|     } | ||||
|   }); | ||||
|  |  | |||
|  | @ -0,0 +1,6 @@ | |||
| export enum LayerActionID { | ||||
|   Delete = 'delete', | ||||
|   Duplicate = 'duplicate', | ||||
|   MoveTop = 'move-top', | ||||
|   MoveBottom = 'move-bottom', | ||||
| } | ||||
		Loading…
	
		Reference in New Issue