From 12a45fdeca21500cbfecbec72d2d119dcd966804 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Wed, 12 Jun 2024 18:33:42 -0600 Subject: [PATCH] Canvas: Refactor scene.tsx for better maintainability (#88517) Co-authored-by: drew08t --- public/app/features/canvas/runtime/ables.tsx | 3 +- public/app/features/canvas/runtime/frame.tsx | 3 +- public/app/features/canvas/runtime/scene.tsx | 546 +----------------- .../canvas/runtime/sceneAbleManagement.ts | 382 ++++++++++++ .../canvas/runtime/sceneElementManagement.ts | 170 ++++++ .../canvas/components/CanvasContextMenu.tsx | 3 +- .../components/connections/Connections.tsx | 3 +- .../editor/layer/TreeNavigationEditor.tsx | 5 +- 8 files changed, 572 insertions(+), 543 deletions(-) create mode 100644 public/app/features/canvas/runtime/sceneAbleManagement.ts create mode 100644 public/app/features/canvas/runtime/sceneElementManagement.ts diff --git a/public/app/features/canvas/runtime/ables.tsx b/public/app/features/canvas/runtime/ables.tsx index 60689852b33..c84a1a06a83 100644 --- a/public/app/features/canvas/runtime/ables.tsx +++ b/public/app/features/canvas/runtime/ables.tsx @@ -3,6 +3,7 @@ import { MoveableManagerInterface, Renderer } from 'moveable'; import { VerticalConstraint, HorizontalConstraint } from 'app/plugins/panel/canvas/panelcfg.gen'; import { Scene } from './scene'; +import { findElementByTarget } from './sceneElementManagement'; export const settingsViewable = (scene: Scene) => ({ name: 'settingsViewable', @@ -99,7 +100,7 @@ export const constraintViewable = (scene: Scene) => ({ events: [], render(moveable: MoveableManagerInterface, React: Renderer) { const rect = moveable.getRect(); - const targetElement = scene.findElementByTarget(moveable.state.target!); + const targetElement = findElementByTarget(moveable.state.target!, scene.root.elements); // If selection is more than 1 element don't display constraint visualizations if (scene.selecto?.getSelectedTargets() && scene.selecto?.getSelectedTargets().length > 1) { diff --git a/public/app/features/canvas/runtime/frame.tsx b/public/app/features/canvas/runtime/frame.tsx index 99cdb1abe80..7307fdb5513 100644 --- a/public/app/features/canvas/runtime/frame.tsx +++ b/public/app/features/canvas/runtime/frame.tsx @@ -14,6 +14,7 @@ import { canvasElementRegistry } from '../registry'; import { ElementState } from './element'; import { RootElement } from './root'; import { Scene } from './scene'; +import { initMoveable } from './sceneAbleManagement'; const DEFAULT_OFFSET = 10; const HORIZONTAL_OFFSET = 50; @@ -111,7 +112,7 @@ export class FrameState extends ElementState { reinitializeMoveable() { // Need to first clear current selection and then re-init moveable with slight delay this.scene.clearCurrentSelection(); - setTimeout(() => this.scene.initMoveable(true, this.scene.isEditingEnabled)); + setTimeout(() => initMoveable(true, this.scene.isEditingEnabled, this.scene)); } // ??? or should this be on the element directly? diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 77ab717f611..5df905d1dd8 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -3,7 +3,6 @@ import Moveable from 'moveable'; import React, { createRef, CSSProperties, RefObject } from 'react'; import { ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'; import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'; -import { first } from 'rxjs/operators'; import Selecto from 'selecto'; import { AppEvents, PanelData } from '@grafana/data'; @@ -27,15 +26,9 @@ import { } from 'app/features/dimensions/utils'; import { CanvasContextMenu } from 'app/plugins/panel/canvas/components/CanvasContextMenu'; import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip'; -import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; -import { - Connections, - CONNECTION_VERTEX_ADD_ID, - CONNECTION_VERTEX_ID, -} from 'app/plugins/panel/canvas/components/connections/Connections'; -import { HorizontalConstraint, Placement, VerticalConstraint } from 'app/plugins/panel/canvas/panelcfg.gen'; -import { AnchorPoint, CanvasTooltipPayload, LayerActionID } from 'app/plugins/panel/canvas/types'; -import { getParent, getTransformInstance } from 'app/plugins/panel/canvas/utils'; +import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections'; +import { AnchorPoint, CanvasTooltipPayload } from 'app/plugins/panel/canvas/types'; +import { getTransformInstance } from 'app/plugins/panel/canvas/utils'; import appEvents from '../../../core/app_events'; import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel'; @@ -43,10 +36,11 @@ import { CanvasFrameOptions } from '../frame'; import { DEFAULT_CANVAS_ELEMENT_CONFIG } from '../registry'; import { SceneTransformWrapper } from './SceneTransformWrapper'; -import { constraintViewable, dimensionViewable, settingsViewable } from './ables'; import { ElementState } from './element'; import { FrameState } from './frame'; import { RootElement } from './root'; +import { initMoveable } from './sceneAbleManagement'; +import { findElementByTarget } from './sceneElementManagement'; export interface SelectionParams { targets: Array; @@ -176,7 +170,7 @@ export class Scene { if (this.div) { // If editing is enabled, clear selecto instance const destroySelecto = enableEditing; - this.initMoveable(destroySelecto, enableEditing); + initMoveable(destroySelecto, enableEditing, this); this.currentLayer = this.root; this.selection.next([]); this.connections.select(undefined); @@ -210,126 +204,24 @@ export class Scene { } } - 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(); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - element.setPlacementFromConstraint(elementContainer, framePlacement as DOMRect); - currentLayer.doAction(LayerActionID.Delete, element); - newLayer.doAction(LayerActionID.Duplicate, element, false, false); - }); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - 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); + initMoveable(true, this.isEditingEnabled, this); } }); } }; - findElementByTarget = (target: Element): 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; - }; - setNonTargetPointerEvents = (target: Element, disablePointerEvents: boolean) => { const stack = [...this.root.elements]; while (stack.length > 0) { @@ -363,7 +255,7 @@ export class Scene { } }; - private updateSelection = (selection: SelectionParams) => { + updateSelection = (selection: SelectionParams) => { this.moveable!.target = selection.targets; if (this.skipNextSelectionBroadcast) { this.skipNextSelectionBroadcast = false; @@ -373,431 +265,11 @@ export class Scene { if (selection.frame) { this.selection.next([selection.frame]); } else { - const s = selection.targets.map((t) => this.findElementByTarget(t)!); + const s = selection.targets.map((t) => findElementByTarget(t, this.root.elements)!); 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; - }; - - disableCustomables = () => { - this.moveable!.props = { - dimensionViewable: false, - constraintViewable: false, - settingsViewable: false, - }; - }; - - enableCustomables = () => { - this.moveable!.props = { - dimensionViewable: true, - constraintViewable: true, - settingsViewable: true, - }; - }; - - 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, - rootContainer: getParent(this), - selectableTargets: targetElements, - toggleContinueSelect: 'shift', - selectFromInside: false, - hitRate: 0, - }); - - const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; - const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; - - this.moveable = new Moveable(this.div!, { - draggable: allowChanges && !this.editModeEnabled.getValue(), - resizable: allowChanges, - - // Setup rotatable - rotatable: allowChanges, - throttleRotate: 5, - rotationPosition: ['top', 'right'], - - // Setup snappable - snappable: allowChanges, - snapDirections: snapDirections, - elementSnapDirections: elementSnapDirections, - elementGuidelines: targetElements, - - ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)], - props: { - dimensionViewable: allowChanges, - constraintViewable: allowChanges, - settingsViewable: allowChanges, - }, - origin: false, - }) - .on('rotateStart', () => { - this.disableCustomables(); - }) - .on('rotate', (event) => { - const targetedElement = this.findElementByTarget(event.target); - - if (targetedElement) { - targetedElement.applyRotate(event); - } - }) - .on('rotateGroup', (e) => { - for (let event of e.events) { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - targetedElement.applyRotate(event); - } - } - }) - .on('rotateEnd', () => { - this.enableCustomables(); - // Update the editor with the new rotation - this.moved.next(Date.now()); - }) - .on('click', (event) => { - const targetedElement = this.findElementByTarget(event.target); - let elementSupportsEditing = false; - if (targetedElement) { - elementSupportsEditing = targetedElement.item.hasEditMode ?? false; - } - - if (event.isDouble && allowChanges && !this.editModeEnabled.getValue() && elementSupportsEditing) { - this.editModeEnabled.next(true); - } - }) - .on('clickGroup', (event) => { - this.selecto!.clickTarget(event.inputEvent, event.inputTarget); - }) - .on('dragStart', (event) => { - this.ignoreDataUpdate = true; - this.setNonTargetPointerEvents(event.target, true); - - // Remove the selected element from the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); - if (targetIndex > -1) { - this.moveable.elementGuidelines.splice(targetIndex, 1); - } - } - }) - .on('dragGroupStart', (e) => { - this.ignoreDataUpdate = true; - - // Remove the selected elements from the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - for (let event of e.events) { - const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); - if (targetIndex > -1) { - this.moveable.elementGuidelines.splice(targetIndex, 1); - } - } - } - }) - .on('drag', (event) => { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - targetedElement.applyDrag(event); - - if (this.connections.connectionsNeedUpdate(targetedElement) && this.moveableActionCallback) { - this.moveableActionCallback(true); - } - } - }) - .on('dragGroup', (e) => { - let needsUpdate = false; - for (let event of e.events) { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - targetedElement.applyDrag(event); - if (!needsUpdate) { - needsUpdate = this.connections.connectionsNeedUpdate(targetedElement); - } - } - } - - if (needsUpdate && this.moveableActionCallback) { - this.moveableActionCallback(true); - } - }) - .on('dragGroupEnd', (e) => { - e.events.forEach((event) => { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - if (targetedElement) { - targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); - } - - // re-add the selected elements to the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - this.moveable.elementGuidelines.push(event.target); - } - } - }); - - this.moved.next(Date.now()); - this.ignoreDataUpdate = false; - }) - .on('dragEnd', (event) => { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); - } - - this.moved.next(Date.now()); - this.ignoreDataUpdate = false; - this.setNonTargetPointerEvents(event.target, false); - - // re-add the selected element to the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - this.moveable.elementGuidelines.push(event.target); - } - }) - .on('resizeStart', (event) => { - const targetedElement = this.findElementByTarget(event.target); - - if (targetedElement) { - // Remove the selected element from the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); - if (targetIndex > -1) { - this.moveable.elementGuidelines.splice(targetIndex, 1); - } - } - - targetedElement.tempConstraint = { ...targetedElement.options.constraint }; - targetedElement.options.constraint = { - vertical: VerticalConstraint.Top, - horizontal: HorizontalConstraint.Left, - }; - targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); - } - }) - .on('resizeGroupStart', (e) => { - // Remove the selected elements from the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - for (let event of e.events) { - const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); - if (targetIndex > -1) { - this.moveable.elementGuidelines.splice(targetIndex, 1); - } - } - } - }) - .on('resize', (event) => { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - targetedElement.applyResize(event, this.scale); - - if (this.connections.connectionsNeedUpdate(targetedElement) && this.moveableActionCallback) { - this.moveableActionCallback(true); - } - } - this.moved.next(Date.now()); // TODO only on end - }) - .on('resizeGroup', (e) => { - let needsUpdate = false; - for (let event of e.events) { - const targetedElement = this.findElementByTarget(event.target); - if (targetedElement) { - targetedElement.applyResize(event); - - if (!needsUpdate) { - needsUpdate = this.connections.connectionsNeedUpdate(targetedElement); - } - } - } - - if (needsUpdate && this.moveableActionCallback) { - this.moveableActionCallback(true); - } - - 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(undefined, undefined, this.scale); - - // re-add the selected element to the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - this.moveable.elementGuidelines.push(event.target); - } - } - }) - .on('resizeGroupEnd', (e) => { - // re-add the selected elements to the snappable guidelines - if (this.moveable && this.moveable.elementGuidelines) { - for (let event of e.events) { - this.moveable.elementGuidelines.push(event.target); - } - } - }); - - let targets: Array = []; - this.selecto!.on('dragStart', (event) => { - const selectedTarget = event.inputEvent.target; - - // If selected target is a connection control, eject to handle connection event - if (selectedTarget.id === CONNECTION_ANCHOR_DIV_ID) { - this.connections.handleConnectionDragStart(selectedTarget, event.inputEvent.clientX, event.inputEvent.clientY); - event.stop(); - return; - } - - // If selected target is a vertex, eject to handle vertex event - if (selectedTarget.id === CONNECTION_VERTEX_ID) { - this.connections.handleVertexDragStart(selectedTarget); - event.stop(); - return; - } - - // If selected target is an add vertex point, eject to handle add vertex event - if (selectedTarget.id === CONNECTION_VERTEX_ADD_ID) { - this.connections.handleVertexAddDragStart(selectedTarget); - event.stop(); - return; - } - - const isTargetMoveableElement = - this.moveable!.isMoveableElement(selectedTarget) || - targets.some((target) => target === selectedTarget || target.contains(selectedTarget)); - - const isTargetAlreadySelected = this.selecto - ?.getSelectedTargets() - .includes(selectedTarget.parentElement.parentElement); - - // Apply grabbing cursor while dragging, applyLayoutStylesToDiv() resets it to grab when done - if ( - this.isEditingEnabled && - !this.editModeEnabled.getValue() && - isTargetMoveableElement && - this.selecto?.getSelectedTargets().length - ) { - this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; - } - - if (isTargetMoveableElement || isTargetAlreadySelected || !this.isEditingEnabled) { - // Prevent drawing selection box when selected target is a moveable element or already selected - event.stop(); - } - }) - .on('select', () => { - this.editModeEnabled.next(false); - - // Hide connection anchors on select - if (this.connections.connectionAnchorDiv) { - this.connections.connectionAnchorDiv.style.display = 'none'; - } - }) - .on('selectEnd', (event) => { - targets = event.selected; - this.updateSelection({ targets }); - - if (event.isDragStart) { - if (this.isEditingEnabled && !this.editModeEnabled.getValue() && this.selecto?.getSelectedTargets().length) { - this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; - } - event.inputEvent.preventDefault(); - event.data.timer = setTimeout(() => { - this.moveable!.dragStart(event.inputEvent); - }); - } - }) - .on('dragEnd', (event) => { - clearTimeout(event.data.timer); - }); - }; - - reorderElements = (src: ElementState, dest: ElementState, dragToGap: boolean, destPosition: number) => { - switch (dragToGap) { - case true: - switch (destPosition) { - case -1: - // top of the tree - if (src.parent instanceof FrameState) { - // move outside the frame - if (dest.parent) { - this.updateElements(src, dest.parent, dest.parent.elements.length); - src.updateData(dest.parent.scene.context); - } - } else { - dest.parent?.reorderTree(src, dest, true); - } - break; - default: - if (dest.parent) { - this.updateElements(src, dest.parent, dest.parent.elements.indexOf(dest)); - src.updateData(dest.parent.scene.context); - } - break; - } - break; - case false: - if (dest instanceof FrameState) { - if (src.parent === dest) { - // same frame parent - src.parent?.reorderTree(src, dest, true); - } else { - this.updateElements(src, dest); - src.updateData(dest.scene.context); - } - } else if (src.parent === dest.parent) { - src.parent?.reorderTree(src, dest); - } else { - if (dest.parent) { - this.updateElements(src, dest.parent); - src.updateData(dest.parent.scene.context); - } - } - break; - } - }; - - private updateElements = (src: ElementState, dest: FrameState | RootElement, idx: number | null = null) => { - src.parent?.doAction(LayerActionID.Delete, src); - src.parent = dest; - - const elementContainer = src.div?.getBoundingClientRect(); - src.setPlacementFromConstraint(elementContainer, dest.div?.getBoundingClientRect()); - - const destIndex = idx ?? dest.elements.length - 1; - dest.elements.splice(destIndex, 0, src); - dest.scene.save(); - - dest.reinitializeMoveable(); - }; - addToSelection = () => { try { let selection: SelectionParams = { targets: [] }; diff --git a/public/app/features/canvas/runtime/sceneAbleManagement.ts b/public/app/features/canvas/runtime/sceneAbleManagement.ts new file mode 100644 index 00000000000..b5b0aa06cc4 --- /dev/null +++ b/public/app/features/canvas/runtime/sceneAbleManagement.ts @@ -0,0 +1,382 @@ +import Moveable from 'moveable'; +import Selecto from 'selecto'; + +import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; +import { + CONNECTION_VERTEX_ID, + CONNECTION_VERTEX_ADD_ID, +} from 'app/plugins/panel/canvas/components/connections/Connections'; +import { VerticalConstraint, HorizontalConstraint } from 'app/plugins/panel/canvas/panelcfg.gen'; +import { getParent } from 'app/plugins/panel/canvas/utils'; + +import { dimensionViewable, constraintViewable, settingsViewable } from './ables'; +import { ElementState } from './element'; +import { FrameState } from './frame'; +import { Scene } from './scene'; +import { findElementByTarget } from './sceneElementManagement'; + +// Helper function that disables custom able functionality +const disableCustomables = (moveable: Moveable) => { + moveable!.props = { + dimensionViewable: false, + constraintViewable: false, + settingsViewable: false, + }; +}; + +// Helper function that enables custom able functionality +const enableCustomables = (moveable: Moveable) => { + moveable!.props = { + dimensionViewable: true, + constraintViewable: true, + settingsViewable: true, + }; +}; + +// Generate HTML element divs for every canvas element to configure selecto / moveable +const 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; +}; + +// Main entry point for initializing / updating moveable and selecto configuration +export const initMoveable = (destroySelecto = false, allowChanges = true, scene: Scene) => { + const targetElements = generateTargetElements(scene.root.elements); + + if (destroySelecto && scene.selecto) { + scene.selecto.destroy(); + } + + scene.selecto = new Selecto({ + container: scene.div, + rootContainer: getParent(scene), + selectableTargets: targetElements, + toggleContinueSelect: 'shift', + selectFromInside: false, + hitRate: 0, + }); + + const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; + const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; + + scene.moveable = new Moveable(scene.div!, { + draggable: allowChanges && !scene.editModeEnabled.getValue(), + resizable: allowChanges, + + // Setup rotatable + rotatable: allowChanges, + throttleRotate: 5, + rotationPosition: ['top', 'right'], + + // Setup snappable + snappable: allowChanges, + snapDirections: snapDirections, + elementSnapDirections: elementSnapDirections, + elementGuidelines: targetElements, + + ables: [dimensionViewable, constraintViewable(scene), settingsViewable(scene)], + props: { + dimensionViewable: allowChanges, + constraintViewable: allowChanges, + settingsViewable: allowChanges, + }, + origin: false, + }) + .on('rotateStart', () => { + disableCustomables(scene.moveable!); + }) + .on('rotate', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + + if (targetedElement) { + targetedElement.applyRotate(event); + } + }) + .on('rotateGroup', (e) => { + for (let event of e.events) { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.applyRotate(event); + } + } + }) + .on('rotateEnd', () => { + enableCustomables(scene.moveable!); + // Update the editor with the new rotation + scene.moved.next(Date.now()); + }) + .on('click', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + let elementSupportsEditing = false; + if (targetedElement) { + elementSupportsEditing = targetedElement.item.hasEditMode ?? false; + } + + if (event.isDouble && allowChanges && !scene.editModeEnabled.getValue() && elementSupportsEditing) { + scene.editModeEnabled.next(true); + } + }) + .on('clickGroup', (event) => { + scene.selecto!.clickTarget(event.inputEvent, event.inputTarget); + }) + .on('dragStart', (event) => { + scene.ignoreDataUpdate = true; + scene.setNonTargetPointerEvents(event.target, true); + + // Remove the selected element from the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + const targetIndex = scene.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + scene.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + }) + .on('dragGroupStart', (e) => { + scene.ignoreDataUpdate = true; + + // Remove the selected elements from the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + for (let event of e.events) { + const targetIndex = scene.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + scene.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + } + }) + .on('drag', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.applyDrag(event); + + if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) { + scene.moveableActionCallback(true); + } + } + }) + .on('dragGroup', (e) => { + let needsUpdate = false; + for (let event of e.events) { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.applyDrag(event); + if (!needsUpdate) { + needsUpdate = scene.connections.connectionsNeedUpdate(targetedElement); + } + } + } + + if (needsUpdate && scene.moveableActionCallback) { + scene.moveableActionCallback(true); + } + }) + .on('dragGroupEnd', (e) => { + e.events.forEach((event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + if (targetedElement) { + targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); + } + + // re-add the selected elements to the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + scene.moveable.elementGuidelines.push(event.target); + } + } + }); + + scene.moved.next(Date.now()); + scene.ignoreDataUpdate = false; + }) + .on('dragEnd', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); + } + + scene.moved.next(Date.now()); + scene.ignoreDataUpdate = false; + scene.setNonTargetPointerEvents(event.target, false); + + // re-add the selected element to the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + scene.moveable.elementGuidelines.push(event.target); + } + }) + .on('resizeStart', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + + if (targetedElement) { + // Remove the selected element from the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + const targetIndex = scene.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + scene.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + + targetedElement.tempConstraint = { ...targetedElement.options.constraint }; + targetedElement.options.constraint = { + vertical: VerticalConstraint.Top, + horizontal: HorizontalConstraint.Left, + }; + targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); + } + }) + .on('resizeGroupStart', (e) => { + // Remove the selected elements from the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + for (let event of e.events) { + const targetIndex = scene.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + scene.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + } + }) + .on('resize', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.applyResize(event, scene.scale); + + if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) { + scene.moveableActionCallback(true); + } + } + scene.moved.next(Date.now()); // TODO only on end + }) + .on('resizeGroup', (e) => { + let needsUpdate = false; + for (let event of e.events) { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.applyResize(event); + + if (!needsUpdate) { + needsUpdate = scene.connections.connectionsNeedUpdate(targetedElement); + } + } + } + + if (needsUpdate && scene.moveableActionCallback) { + scene.moveableActionCallback(true); + } + + scene.moved.next(Date.now()); // TODO only on end + }) + .on('resizeEnd', (event) => { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + + if (targetedElement) { + if (targetedElement.tempConstraint) { + targetedElement.options.constraint = targetedElement.tempConstraint; + targetedElement.tempConstraint = undefined; + } + + targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); + + // re-add the selected element to the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + scene.moveable.elementGuidelines.push(event.target); + } + } + }) + .on('resizeGroupEnd', (e) => { + // re-add the selected elements to the snappable guidelines + if (scene.moveable && scene.moveable.elementGuidelines) { + for (let event of e.events) { + scene.moveable.elementGuidelines.push(event.target); + } + } + }); + + let targets: Array = []; + scene + .selecto!.on('dragStart', (event) => { + const selectedTarget = event.inputEvent.target; + + // If selected target is a connection control, eject to handle connection event + if (selectedTarget.id === CONNECTION_ANCHOR_DIV_ID) { + scene.connections.handleConnectionDragStart(selectedTarget, event.inputEvent.clientX, event.inputEvent.clientY); + event.stop(); + return; + } + + // If selected target is a vertex, eject to handle vertex event + if (selectedTarget.id === CONNECTION_VERTEX_ID) { + scene.connections.handleVertexDragStart(selectedTarget); + event.stop(); + return; + } + + // If selected target is an add vertex point, eject to handle add vertex event + if (selectedTarget.id === CONNECTION_VERTEX_ADD_ID) { + scene.connections.handleVertexAddDragStart(selectedTarget); + event.stop(); + return; + } + + const isTargetMoveableElement = + scene.moveable!.isMoveableElement(selectedTarget) || + targets.some((target) => target === selectedTarget || target.contains(selectedTarget)); + + const isTargetAlreadySelected = scene.selecto + ?.getSelectedTargets() + .includes(selectedTarget.parentElement.parentElement); + + // Apply grabbing cursor while dragging, applyLayoutStylesToDiv() resets it to grab when done + if ( + scene.isEditingEnabled && + !scene.editModeEnabled.getValue() && + isTargetMoveableElement && + scene.selecto?.getSelectedTargets().length + ) { + scene.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; + } + + if (isTargetMoveableElement || isTargetAlreadySelected || !scene.isEditingEnabled) { + // Prevent drawing selection box when selected target is a moveable element or already selected + event.stop(); + } + }) + .on('select', () => { + scene.editModeEnabled.next(false); + + // Hide connection anchors on select + if (scene.connections.connectionAnchorDiv) { + scene.connections.connectionAnchorDiv.style.display = 'none'; + } + }) + .on('selectEnd', (event) => { + targets = event.selected; + scene.updateSelection({ targets }); + + if (event.isDragStart) { + if (scene.isEditingEnabled && !scene.editModeEnabled.getValue() && scene.selecto?.getSelectedTargets().length) { + scene.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; + } + event.inputEvent.preventDefault(); + event.data.timer = setTimeout(() => { + scene.moveable!.dragStart(event.inputEvent); + }); + } + }) + .on('dragEnd', (event) => { + clearTimeout(event.data.timer); + }); +}; diff --git a/public/app/features/canvas/runtime/sceneElementManagement.ts b/public/app/features/canvas/runtime/sceneElementManagement.ts new file mode 100644 index 00000000000..9a2e94db2d0 --- /dev/null +++ b/public/app/features/canvas/runtime/sceneElementManagement.ts @@ -0,0 +1,170 @@ +import { first } from 'rxjs/operators'; + +import { Placement } from 'app/plugins/panel/canvas/panelcfg.gen'; +import { LayerActionID } from 'app/plugins/panel/canvas/types'; + +import { ElementState } from './element'; +import { FrameState } from './frame'; +import { RootElement } from './root'; +import { Scene } from './scene'; + +// TODO: Consider whether or not reorderElements + updateElements should be moved to TreeNavigationEditor +// Reorder elements in the DOM when rearranging them in the tree navigation editor +export const reorderElements = (src: ElementState, dest: ElementState, dragToGap: boolean, destPosition: number) => { + switch (dragToGap) { + case true: + switch (destPosition) { + case -1: + // top of the tree + if (src.parent instanceof FrameState) { + // move outside the frame + if (dest.parent) { + updateElements(src, dest.parent, dest.parent.elements.length); + src.updateData(dest.parent.scene.context); + } + } else { + dest.parent?.reorderTree(src, dest, true); + } + break; + default: + if (dest.parent) { + updateElements(src, dest.parent, dest.parent.elements.indexOf(dest)); + src.updateData(dest.parent.scene.context); + } + break; + } + break; + case false: + if (dest instanceof FrameState) { + if (src.parent === dest) { + // same frame parent + src.parent?.reorderTree(src, dest, true); + } else { + updateElements(src, dest); + src.updateData(dest.scene.context); + } + } else if (src.parent === dest.parent) { + src.parent?.reorderTree(src, dest); + } else { + if (dest.parent) { + updateElements(src, dest.parent); + src.updateData(dest.parent.scene.context); + } + } + break; + } +}; + +// Reorders canvas elements +const updateElements = (src: ElementState, dest: FrameState | RootElement, idx: number | null = null) => { + src.parent?.doAction(LayerActionID.Delete, src); + src.parent = dest; + + const elementContainer = src.div?.getBoundingClientRect(); + src.setPlacementFromConstraint(elementContainer, dest.div?.getBoundingClientRect()); + + const destIndex = idx ?? dest.elements.length - 1; + dest.elements.splice(destIndex, 0, src); + dest.scene.save(); + + dest.reinitializeMoveable(); +}; + +// Finds the element state if it exists for a given DOM element +export const findElementByTarget = (target: Element, sceneElements: ElementState[]): ElementState | undefined => { + // We will probably want to add memoization to this as we are calling on drag / resize + + const stack = [...sceneElements]; + 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; +}; + +// Nest selected elements into a frame object +export const frameSelection = (scene: Scene) => { + scene.selection.pipe(first()).subscribe((currentSelectedElements) => { + const currentLayer = currentSelectedElements[0].parent!; + + const newLayer = new FrameState( + { + type: 'frame', + name: scene.getNextElementName(true), + elements: [], + }, + scene, + currentSelectedElements[0].parent + ); + + const framePlacement = generateFrameContainer(currentSelectedElements); + + newLayer.options.placement = framePlacement; + + currentSelectedElements.forEach((element: ElementState) => { + const elementContainer = element.div?.getBoundingClientRect(); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + element.setPlacementFromConstraint(elementContainer, framePlacement as DOMRect); + currentLayer.doAction(LayerActionID.Delete, element); + newLayer.doAction(LayerActionID.Duplicate, element, false, false); + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + newLayer.setPlacementFromConstraint(framePlacement as DOMRect, currentLayer.div?.getBoundingClientRect()); + + currentLayer.elements.push(newLayer); + + scene.byName.set(newLayer.getName(), newLayer); + + scene.save(); + }); +}; + +// Helper function to generate the a frame object based on the selected elements' dimensions +const 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, + }; +}; diff --git a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx index e97619f8e0a..b2b3d55833f 100644 --- a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx @@ -7,6 +7,7 @@ import { ContextMenu, MenuItem, MenuItemProps } from '@grafana/ui'; import { ElementState } from 'app/features/canvas/runtime/element'; import { FrameState } from 'app/features/canvas/runtime/frame'; import { Scene } from 'app/features/canvas/runtime/scene'; +import { findElementByTarget } from 'app/features/canvas/runtime/sceneElementManagement'; import { CanvasPanel } from '../CanvasPanel'; import { AnchorPoint, LayerActionID } from '../types'; @@ -96,7 +97,7 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) = closeContextMenu(); }; - const element = scene.findElementByTarget(selectedElements[0]); + const element = findElementByTarget(selectedElements[0], scene.root.elements); return ( element && element.item.hasEditMode && ( diff --git a/public/app/plugins/panel/canvas/components/connections/Connections.tsx b/public/app/plugins/panel/canvas/components/connections/Connections.tsx index 26460a819c6..f1ed0042875 100644 --- a/public/app/plugins/panel/canvas/components/connections/Connections.tsx +++ b/public/app/plugins/panel/canvas/components/connections/Connections.tsx @@ -5,6 +5,7 @@ import { config } from '@grafana/runtime'; import { CanvasConnection, ConnectionCoordinates, ConnectionPath } from 'app/features/canvas/element'; import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; +import { findElementByTarget } from 'app/features/canvas/runtime/sceneElementManagement'; import { ConnectionState } from '../../types'; import { @@ -111,7 +112,7 @@ export class Connections { return undefined; } - elementTarget = this.scene.findElementByTarget(element); + elementTarget = findElementByTarget(element, this.scene.root.elements); if (!elementTarget && element.parentElement) { elementTarget = this.findElementTarget(element.parentElement); diff --git a/public/app/plugins/panel/canvas/editor/layer/TreeNavigationEditor.tsx b/public/app/plugins/panel/canvas/editor/layer/TreeNavigationEditor.tsx index c6d78e7480f..8f1a75617b8 100644 --- a/public/app/plugins/panel/canvas/editor/layer/TreeNavigationEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/layer/TreeNavigationEditor.tsx @@ -8,6 +8,7 @@ import { config } from '@grafana/runtime'; import { Button, Icon, Stack, useStyles2, useTheme2 } from '@grafana/ui'; import { AddLayerButton } from 'app/core/components/Layers/AddLayerButton'; import { ElementState } from 'app/features/canvas/runtime/element'; +import { frameSelection, reorderElements } from 'app/features/canvas/runtime/sceneElementManagement'; import { getGlobalStyles } from '../../globalStyles'; import { Options } from '../../panelcfg.gen'; @@ -77,7 +78,7 @@ export const TreeNavigationEditor = ({ item }: StandardEditorProps { @@ -118,7 +119,7 @@ export const TreeNavigationEditor = ({ item }: StandardEditorProps { if (layer.scene) { - layer.scene.frameSelection(); + frameSelection(layer.scene); } else { console.warn('no scene!'); }