123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- import { css } from '@emotion/css';
- import Moveable from 'moveable';
- import React, { CSSProperties } from 'react';
- import { ReplaySubject, Subject } from 'rxjs';
- import { first } from 'rxjs/operators';
- import Selecto from 'selecto';
- import { GrafanaTheme2, PanelData } from '@grafana/data';
- import { locationService } from '@grafana/runtime/src';
- import { Portal, stylesFactory } from '@grafana/ui';
- import { config } from 'app/core/config';
- import { CanvasFrameOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
- import {
- ColorDimensionConfig,
- DimensionContext,
- ResourceDimensionConfig,
- ScalarDimensionConfig,
- ScaleDimensionConfig,
- TextDimensionConfig,
- } from 'app/features/dimensions';
- import {
- getColorDimensionFromData,
- getResourceDimensionFromData,
- getScalarDimensionFromData,
- getScaleDimensionFromData,
- getTextDimensionFromData,
- } from 'app/features/dimensions/utils';
- import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
- import { LayerActionID } from 'app/plugins/panel/canvas/types';
- import { HorizontalConstraint, Placement, VerticalConstraint } from '../types';
- import { constraintViewable, dimensionViewable } from './ables';
- import { ElementState } from './element';
- import { FrameState } from './frame';
- import { RootElement } from './root';
- export interface SelectionParams {
- targets: Array<HTMLElement | SVGElement>;
- frame?: FrameState;
- }
- export class Scene {
- styles = getStyles(config.theme2);
- readonly selection = new ReplaySubject<ElementState[]>(1);
- readonly moved = new Subject<number>(); // called after resize/drag for editor updates
- readonly byName = new Map<string, ElementState>();
- root: RootElement;
- revId = 0;
- width = 0;
- height = 0;
- style: CSSProperties = {};
- data?: PanelData;
- selecto?: Selecto;
- moveable?: Moveable;
- div?: HTMLDivElement;
- currentLayer?: FrameState;
- isEditingEnabled?: boolean;
- skipNextSelectionBroadcast = false;
- isPanelEditing = locationService.getSearchObject().editPanel !== undefined;
- constructor(cfg: CanvasFrameOptions, enableEditing: boolean, public onSave: (cfg: CanvasFrameOptions) => void) {
- this.root = this.load(cfg, enableEditing);
- }
- getNextElementName = (isFrame = false) => {
- const label = isFrame ? 'Frame' : 'Element';
- let idx = this.byName.size + 1;
- const max = idx + 100;
- while (true && idx < max) {
- const name = `${label} ${idx++}`;
- if (!this.byName.has(name)) {
- return name;
- }
- }
- return `${label} ${Date.now()}`;
- };
- canRename = (v: string) => {
- return !this.byName.has(v);
- };
- load(cfg: CanvasFrameOptions, enableEditing: boolean) {
- this.root = new RootElement(
- cfg ?? {
- type: 'frame',
- elements: [DEFAULT_CANVAS_ELEMENT_CONFIG],
- },
- this,
- this.save // callback when changes are made
- );
- this.isEditingEnabled = enableEditing;
- setTimeout(() => {
- if (this.div) {
- // If editing is enabled, clear selecto instance
- const destroySelecto = enableEditing;
- this.initMoveable(destroySelecto, enableEditing);
- this.currentLayer = this.root;
- this.selection.next([]);
- }
- });
- return this.root;
- }
- context: DimensionContext = {
- getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color),
- getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale),
- getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
- getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
- getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
- };
- updateData(data: PanelData) {
- this.data = data;
- this.root.updateData(this.context);
- }
- updateSize(width: number, height: number) {
- this.width = width;
- this.height = height;
- this.style = { width, height };
- if (this.selecto?.getSelectedTargets().length) {
- this.clearCurrentSelection();
- }
- }
- frameSelection() {
- this.selection.pipe(first()).subscribe((currentSelectedElements) => {
- const currentLayer = currentSelectedElements[0].parent!;
- const newLayer = new FrameState(
- {
- type: 'frame',
- name: this.getNextElementName(true),
- elements: [],
- },
- this,
- currentSelectedElements[0].parent
- );
- const framePlacement = this.generateFrameContainer(currentSelectedElements);
- newLayer.options.placement = framePlacement;
- currentSelectedElements.forEach((element: ElementState) => {
- const elementContainer = element.div?.getBoundingClientRect();
- element.setPlacementFromConstraint(elementContainer, framePlacement as DOMRect);
- currentLayer.doAction(LayerActionID.Delete, element);
- newLayer.doAction(LayerActionID.Duplicate, element, false, false);
- });
- newLayer.setPlacementFromConstraint(framePlacement as DOMRect, currentLayer.div?.getBoundingClientRect());
- currentLayer.elements.push(newLayer);
- this.byName.set(newLayer.getName(), newLayer);
- this.save();
- });
- }
- private generateFrameContainer = (elements: ElementState[]): Placement => {
- let minTop = Infinity;
- let minLeft = Infinity;
- let maxRight = 0;
- let maxBottom = 0;
- elements.forEach((element: ElementState) => {
- const elementContainer = element.div?.getBoundingClientRect();
- if (!elementContainer) {
- return;
- }
- if (minTop > elementContainer.top) {
- minTop = elementContainer.top;
- }
- if (minLeft > elementContainer.left) {
- minLeft = elementContainer.left;
- }
- if (maxRight < elementContainer.right) {
- maxRight = elementContainer.right;
- }
- if (maxBottom < elementContainer.bottom) {
- maxBottom = elementContainer.bottom;
- }
- });
- return {
- top: minTop,
- left: minLeft,
- width: maxRight - minLeft,
- height: maxBottom - minTop,
- };
- };
- clearCurrentSelection(skipNextSelectionBroadcast = false) {
- this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
- let event: MouseEvent = new MouseEvent('click');
- this.selecto?.clickTarget(event, this.div);
- }
- updateCurrentLayer(newLayer: FrameState) {
- this.currentLayer = newLayer;
- this.clearCurrentSelection();
- this.save();
- }
- save = (updateMoveable = false) => {
- this.onSave(this.root.getSaveModel());
- if (updateMoveable) {
- setTimeout(() => {
- if (this.div) {
- this.initMoveable(true, this.isEditingEnabled);
- }
- });
- }
- };
- findElementByTarget = (target: HTMLElement | SVGElement): ElementState | undefined => {
- // We will probably want to add memoization to this as we are calling on drag / resize
- const stack = [...this.root.elements];
- while (stack.length > 0) {
- const currentElement = stack.shift();
- if (currentElement && currentElement.div && currentElement.div === target) {
- return currentElement;
- }
- const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
- for (const nestedElement of nestedElements) {
- stack.unshift(nestedElement);
- }
- }
- return undefined;
- };
- setRef = (sceneContainer: HTMLDivElement) => {
- this.div = sceneContainer;
- };
- select = (selection: SelectionParams) => {
- if (this.selecto) {
- this.selecto.setSelectedTargets(selection.targets);
- this.updateSelection(selection);
- }
- };
- private updateSelection = (selection: SelectionParams) => {
- this.moveable!.target = selection.targets;
- if (this.skipNextSelectionBroadcast) {
- this.skipNextSelectionBroadcast = false;
- return;
- }
- if (selection.frame) {
- this.selection.next([selection.frame]);
- } else {
- const s = selection.targets.map((t) => this.findElementByTarget(t)!);
- this.selection.next(s);
- }
- };
- private generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => {
- let targetElements: HTMLDivElement[] = [];
- const stack = [...rootElements];
- while (stack.length > 0) {
- const currentElement = stack.shift();
- if (currentElement && currentElement.div) {
- targetElements.push(currentElement.div);
- }
- const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
- for (const nestedElement of nestedElements) {
- stack.unshift(nestedElement);
- }
- }
- return targetElements;
- };
- initMoveable = (destroySelecto = false, allowChanges = true) => {
- const targetElements = this.generateTargetElements(this.root.elements);
- if (destroySelecto && this.selecto) {
- this.selecto.destroy();
- }
- this.selecto = new Selecto({
- container: this.div,
- selectableTargets: targetElements,
- selectByClick: true,
- });
- this.moveable = new Moveable(this.div!, {
- draggable: allowChanges,
- resizable: allowChanges,
- ables: [dimensionViewable, constraintViewable(this)],
- props: {
- dimensionViewable: allowChanges,
- constraintViewable: allowChanges,
- },
- origin: false,
- className: this.styles.selected,
- })
- .on('clickGroup', (event) => {
- this.selecto!.clickTarget(event.inputEvent, event.inputTarget);
- })
- .on('dragStart', (event) => {
- const targetedElement = this.findElementByTarget(event.target);
- if (targetedElement) {
- targetedElement.isMoving = true;
- }
- })
- .on('drag', (event) => {
- const targetedElement = this.findElementByTarget(event.target);
- targetedElement!.applyDrag(event);
- })
- .on('dragGroup', (e) => {
- e.events.forEach((event) => {
- const targetedElement = this.findElementByTarget(event.target);
- targetedElement!.applyDrag(event);
- });
- })
- .on('dragEnd', (event) => {
- const targetedElement = this.findElementByTarget(event.target);
- if (targetedElement) {
- targetedElement.setPlacementFromConstraint();
- targetedElement.isMoving = false;
- }
- this.moved.next(Date.now());
- })
- .on('resizeStart', (event) => {
- const targetedElement = this.findElementByTarget(event.target);
- if (targetedElement) {
- targetedElement.tempConstraint = { ...targetedElement.options.constraint };
- targetedElement.options.constraint = {
- vertical: VerticalConstraint.Top,
- horizontal: HorizontalConstraint.Left,
- };
- targetedElement.setPlacementFromConstraint();
- }
- })
- .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
- })
- .on('resizeEnd', (event) => {
- const targetedElement = this.findElementByTarget(event.target);
- if (targetedElement) {
- if (targetedElement.tempConstraint) {
- targetedElement.options.constraint = targetedElement.tempConstraint;
- targetedElement.tempConstraint = undefined;
- }
- targetedElement.setPlacementFromConstraint();
- }
- });
- let targets: Array<HTMLElement | SVGElement> = [];
- this.selecto!.on('dragStart', (event) => {
- const selectedTarget = event.inputEvent.target;
- const isTargetMoveableElement =
- this.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;
- this.updateSelection({ targets });
- if (event.isDragStart) {
- event.inputEvent.preventDefault();
- setTimeout(() => {
- this.moveable!.dragStart(event.inputEvent);
- });
- }
- });
- };
- render() {
- const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled);
- return (
- <div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
- {this.root.render()}
- {canShowContextMenu && (
- <Portal>
- <CanvasContextMenu scene={this} />
- </Portal>
- )}
- </div>
- );
- }
- }
- const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
- wrap: css`
- overflow: hidden;
- position: relative;
- `,
- selected: css`
- z-index: 999 !important;
- `,
- }));
|