scene.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. import { css } from '@emotion/css';
  2. import Moveable from 'moveable';
  3. import React, { CSSProperties } from 'react';
  4. import { ReplaySubject, Subject } from 'rxjs';
  5. import { first } from 'rxjs/operators';
  6. import Selecto from 'selecto';
  7. import { GrafanaTheme2, PanelData } from '@grafana/data';
  8. import { locationService } from '@grafana/runtime/src';
  9. import { Portal, stylesFactory } from '@grafana/ui';
  10. import { config } from 'app/core/config';
  11. import { CanvasFrameOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
  12. import {
  13. ColorDimensionConfig,
  14. DimensionContext,
  15. ResourceDimensionConfig,
  16. ScalarDimensionConfig,
  17. ScaleDimensionConfig,
  18. TextDimensionConfig,
  19. } from 'app/features/dimensions';
  20. import {
  21. getColorDimensionFromData,
  22. getResourceDimensionFromData,
  23. getScalarDimensionFromData,
  24. getScaleDimensionFromData,
  25. getTextDimensionFromData,
  26. } from 'app/features/dimensions/utils';
  27. import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
  28. import { LayerActionID } from 'app/plugins/panel/canvas/types';
  29. import { HorizontalConstraint, Placement, VerticalConstraint } from '../types';
  30. import { constraintViewable, dimensionViewable } from './ables';
  31. import { ElementState } from './element';
  32. import { FrameState } from './frame';
  33. import { RootElement } from './root';
  34. export interface SelectionParams {
  35. targets: Array<HTMLElement | SVGElement>;
  36. frame?: FrameState;
  37. }
  38. export class Scene {
  39. styles = getStyles(config.theme2);
  40. readonly selection = new ReplaySubject<ElementState[]>(1);
  41. readonly moved = new Subject<number>(); // called after resize/drag for editor updates
  42. readonly byName = new Map<string, ElementState>();
  43. root: RootElement;
  44. revId = 0;
  45. width = 0;
  46. height = 0;
  47. style: CSSProperties = {};
  48. data?: PanelData;
  49. selecto?: Selecto;
  50. moveable?: Moveable;
  51. div?: HTMLDivElement;
  52. currentLayer?: FrameState;
  53. isEditingEnabled?: boolean;
  54. skipNextSelectionBroadcast = false;
  55. isPanelEditing = locationService.getSearchObject().editPanel !== undefined;
  56. constructor(cfg: CanvasFrameOptions, enableEditing: boolean, public onSave: (cfg: CanvasFrameOptions) => void) {
  57. this.root = this.load(cfg, enableEditing);
  58. }
  59. getNextElementName = (isFrame = false) => {
  60. const label = isFrame ? 'Frame' : 'Element';
  61. let idx = this.byName.size + 1;
  62. const max = idx + 100;
  63. while (true && idx < max) {
  64. const name = `${label} ${idx++}`;
  65. if (!this.byName.has(name)) {
  66. return name;
  67. }
  68. }
  69. return `${label} ${Date.now()}`;
  70. };
  71. canRename = (v: string) => {
  72. return !this.byName.has(v);
  73. };
  74. load(cfg: CanvasFrameOptions, enableEditing: boolean) {
  75. this.root = new RootElement(
  76. cfg ?? {
  77. type: 'frame',
  78. elements: [DEFAULT_CANVAS_ELEMENT_CONFIG],
  79. },
  80. this,
  81. this.save // callback when changes are made
  82. );
  83. this.isEditingEnabled = enableEditing;
  84. setTimeout(() => {
  85. if (this.div) {
  86. // If editing is enabled, clear selecto instance
  87. const destroySelecto = enableEditing;
  88. this.initMoveable(destroySelecto, enableEditing);
  89. this.currentLayer = this.root;
  90. this.selection.next([]);
  91. }
  92. });
  93. return this.root;
  94. }
  95. context: DimensionContext = {
  96. getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color),
  97. getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale),
  98. getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
  99. getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
  100. getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
  101. };
  102. updateData(data: PanelData) {
  103. this.data = data;
  104. this.root.updateData(this.context);
  105. }
  106. updateSize(width: number, height: number) {
  107. this.width = width;
  108. this.height = height;
  109. this.style = { width, height };
  110. if (this.selecto?.getSelectedTargets().length) {
  111. this.clearCurrentSelection();
  112. }
  113. }
  114. frameSelection() {
  115. this.selection.pipe(first()).subscribe((currentSelectedElements) => {
  116. const currentLayer = currentSelectedElements[0].parent!;
  117. const newLayer = new FrameState(
  118. {
  119. type: 'frame',
  120. name: this.getNextElementName(true),
  121. elements: [],
  122. },
  123. this,
  124. currentSelectedElements[0].parent
  125. );
  126. const framePlacement = this.generateFrameContainer(currentSelectedElements);
  127. newLayer.options.placement = framePlacement;
  128. currentSelectedElements.forEach((element: ElementState) => {
  129. const elementContainer = element.div?.getBoundingClientRect();
  130. element.setPlacementFromConstraint(elementContainer, framePlacement as DOMRect);
  131. currentLayer.doAction(LayerActionID.Delete, element);
  132. newLayer.doAction(LayerActionID.Duplicate, element, false, false);
  133. });
  134. newLayer.setPlacementFromConstraint(framePlacement as DOMRect, currentLayer.div?.getBoundingClientRect());
  135. currentLayer.elements.push(newLayer);
  136. this.byName.set(newLayer.getName(), newLayer);
  137. this.save();
  138. });
  139. }
  140. private generateFrameContainer = (elements: ElementState[]): Placement => {
  141. let minTop = Infinity;
  142. let minLeft = Infinity;
  143. let maxRight = 0;
  144. let maxBottom = 0;
  145. elements.forEach((element: ElementState) => {
  146. const elementContainer = element.div?.getBoundingClientRect();
  147. if (!elementContainer) {
  148. return;
  149. }
  150. if (minTop > elementContainer.top) {
  151. minTop = elementContainer.top;
  152. }
  153. if (minLeft > elementContainer.left) {
  154. minLeft = elementContainer.left;
  155. }
  156. if (maxRight < elementContainer.right) {
  157. maxRight = elementContainer.right;
  158. }
  159. if (maxBottom < elementContainer.bottom) {
  160. maxBottom = elementContainer.bottom;
  161. }
  162. });
  163. return {
  164. top: minTop,
  165. left: minLeft,
  166. width: maxRight - minLeft,
  167. height: maxBottom - minTop,
  168. };
  169. };
  170. clearCurrentSelection(skipNextSelectionBroadcast = false) {
  171. this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
  172. let event: MouseEvent = new MouseEvent('click');
  173. this.selecto?.clickTarget(event, this.div);
  174. }
  175. updateCurrentLayer(newLayer: FrameState) {
  176. this.currentLayer = newLayer;
  177. this.clearCurrentSelection();
  178. this.save();
  179. }
  180. save = (updateMoveable = false) => {
  181. this.onSave(this.root.getSaveModel());
  182. if (updateMoveable) {
  183. setTimeout(() => {
  184. if (this.div) {
  185. this.initMoveable(true, this.isEditingEnabled);
  186. }
  187. });
  188. }
  189. };
  190. findElementByTarget = (target: HTMLElement | SVGElement): ElementState | undefined => {
  191. // We will probably want to add memoization to this as we are calling on drag / resize
  192. const stack = [...this.root.elements];
  193. while (stack.length > 0) {
  194. const currentElement = stack.shift();
  195. if (currentElement && currentElement.div && currentElement.div === target) {
  196. return currentElement;
  197. }
  198. const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
  199. for (const nestedElement of nestedElements) {
  200. stack.unshift(nestedElement);
  201. }
  202. }
  203. return undefined;
  204. };
  205. setRef = (sceneContainer: HTMLDivElement) => {
  206. this.div = sceneContainer;
  207. };
  208. select = (selection: SelectionParams) => {
  209. if (this.selecto) {
  210. this.selecto.setSelectedTargets(selection.targets);
  211. this.updateSelection(selection);
  212. }
  213. };
  214. private updateSelection = (selection: SelectionParams) => {
  215. this.moveable!.target = selection.targets;
  216. if (this.skipNextSelectionBroadcast) {
  217. this.skipNextSelectionBroadcast = false;
  218. return;
  219. }
  220. if (selection.frame) {
  221. this.selection.next([selection.frame]);
  222. } else {
  223. const s = selection.targets.map((t) => this.findElementByTarget(t)!);
  224. this.selection.next(s);
  225. }
  226. };
  227. private generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => {
  228. let targetElements: HTMLDivElement[] = [];
  229. const stack = [...rootElements];
  230. while (stack.length > 0) {
  231. const currentElement = stack.shift();
  232. if (currentElement && currentElement.div) {
  233. targetElements.push(currentElement.div);
  234. }
  235. const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
  236. for (const nestedElement of nestedElements) {
  237. stack.unshift(nestedElement);
  238. }
  239. }
  240. return targetElements;
  241. };
  242. initMoveable = (destroySelecto = false, allowChanges = true) => {
  243. const targetElements = this.generateTargetElements(this.root.elements);
  244. if (destroySelecto && this.selecto) {
  245. this.selecto.destroy();
  246. }
  247. this.selecto = new Selecto({
  248. container: this.div,
  249. selectableTargets: targetElements,
  250. selectByClick: true,
  251. });
  252. this.moveable = new Moveable(this.div!, {
  253. draggable: allowChanges,
  254. resizable: allowChanges,
  255. ables: [dimensionViewable, constraintViewable(this)],
  256. props: {
  257. dimensionViewable: allowChanges,
  258. constraintViewable: allowChanges,
  259. },
  260. origin: false,
  261. className: this.styles.selected,
  262. })
  263. .on('clickGroup', (event) => {
  264. this.selecto!.clickTarget(event.inputEvent, event.inputTarget);
  265. })
  266. .on('dragStart', (event) => {
  267. const targetedElement = this.findElementByTarget(event.target);
  268. if (targetedElement) {
  269. targetedElement.isMoving = true;
  270. }
  271. })
  272. .on('drag', (event) => {
  273. const targetedElement = this.findElementByTarget(event.target);
  274. targetedElement!.applyDrag(event);
  275. })
  276. .on('dragGroup', (e) => {
  277. e.events.forEach((event) => {
  278. const targetedElement = this.findElementByTarget(event.target);
  279. targetedElement!.applyDrag(event);
  280. });
  281. })
  282. .on('dragEnd', (event) => {
  283. const targetedElement = this.findElementByTarget(event.target);
  284. if (targetedElement) {
  285. targetedElement.setPlacementFromConstraint();
  286. targetedElement.isMoving = false;
  287. }
  288. this.moved.next(Date.now());
  289. })
  290. .on('resizeStart', (event) => {
  291. const targetedElement = this.findElementByTarget(event.target);
  292. if (targetedElement) {
  293. targetedElement.tempConstraint = { ...targetedElement.options.constraint };
  294. targetedElement.options.constraint = {
  295. vertical: VerticalConstraint.Top,
  296. horizontal: HorizontalConstraint.Left,
  297. };
  298. targetedElement.setPlacementFromConstraint();
  299. }
  300. })
  301. .on('resize', (event) => {
  302. const targetedElement = this.findElementByTarget(event.target);
  303. targetedElement!.applyResize(event);
  304. this.moved.next(Date.now()); // TODO only on end
  305. })
  306. .on('resizeGroup', (e) => {
  307. e.events.forEach((event) => {
  308. const targetedElement = this.findElementByTarget(event.target);
  309. targetedElement!.applyResize(event);
  310. });
  311. this.moved.next(Date.now()); // TODO only on end
  312. })
  313. .on('resizeEnd', (event) => {
  314. const targetedElement = this.findElementByTarget(event.target);
  315. if (targetedElement) {
  316. if (targetedElement.tempConstraint) {
  317. targetedElement.options.constraint = targetedElement.tempConstraint;
  318. targetedElement.tempConstraint = undefined;
  319. }
  320. targetedElement.setPlacementFromConstraint();
  321. }
  322. });
  323. let targets: Array<HTMLElement | SVGElement> = [];
  324. this.selecto!.on('dragStart', (event) => {
  325. const selectedTarget = event.inputEvent.target;
  326. const isTargetMoveableElement =
  327. this.moveable!.isMoveableElement(selectedTarget) ||
  328. targets.some((target) => target === selectedTarget || target.contains(selectedTarget));
  329. if (isTargetMoveableElement) {
  330. // Prevent drawing selection box when selected target is a moveable element
  331. event.stop();
  332. }
  333. }).on('selectEnd', (event) => {
  334. targets = event.selected;
  335. this.updateSelection({ targets });
  336. if (event.isDragStart) {
  337. event.inputEvent.preventDefault();
  338. setTimeout(() => {
  339. this.moveable!.dragStart(event.inputEvent);
  340. });
  341. }
  342. });
  343. };
  344. render() {
  345. const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled);
  346. return (
  347. <div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
  348. {this.root.render()}
  349. {canShowContextMenu && (
  350. <Portal>
  351. <CanvasContextMenu scene={this} />
  352. </Portal>
  353. )}
  354. </div>
  355. );
  356. }
  357. }
  358. const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
  359. wrap: css`
  360. overflow: hidden;
  361. position: relative;
  362. `,
  363. selected: css`
  364. z-index: 999 !important;
  365. `,
  366. }));