From b1a24232e44f860d3effff0ac7216ad9c3a51d3a Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 12 Jan 2023 19:38:00 -0700 Subject: [PATCH] Canvas: Add support for basic arrows (#57561) Co-authored-by: Ryan McKinley Co-authored-by: Adela Almasan Co-authored-by: Drew Slobodnjak --- public/app/features/canvas/element.ts | 20 ++ .../app/features/canvas/runtime/element.tsx | 6 +- public/app/features/canvas/runtime/frame.tsx | 7 + public/app/features/canvas/runtime/scene.tsx | 22 ++ .../panel/canvas/ConnectionAnchors.tsx | 134 ++++++++++ .../plugins/panel/canvas/ConnectionSVG.tsx | 237 ++++++++++++++++++ .../app/plugins/panel/canvas/Connections.tsx | 200 +++++++++++++++ 7 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 public/app/plugins/panel/canvas/ConnectionAnchors.tsx create mode 100644 public/app/plugins/panel/canvas/ConnectionSVG.tsx create mode 100644 public/app/plugins/panel/canvas/Connections.tsx diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index 2146e7fb972..e2634ea9229 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -27,6 +27,26 @@ export interface CanvasElementOptions { placement?: Placement; background?: BackgroundConfig; border?: LineConfig; + connections?: CanvasConnection[]; +} + +// Unit is percentage from the middle of the element +// 0, 0 middle; -1, -1 bottom left; 1, 1 top right +export interface ConnectionCoordinates { + x: number; + y: number; +} + +export enum ConnectionPath { + Straight = 'straight', +} + +export interface CanvasConnection { + source: ConnectionCoordinates; + target: ConnectionCoordinates; + targetName?: string; + path: ConnectionPath; + // See https://github.com/anseki/leader-line#options for more examples of more properties } export interface CanvasElementProps { diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 0b6bce62ef0..5fec464650e 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -453,7 +453,11 @@ export class ElementState implements LayerElement { const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div); return ( -
+
{ @@ -304,6 +308,11 @@ export class Scene { this.selecto.setSelectedTargets(selection.targets); this.updateSelection(selection); this.editModeEnabled.next(false); + + // Hide connection anchors on programmatic select + if (this.connections.connectionAnchorDiv) { + this.connections.connectionAnchorDiv.style.display = 'none'; + } } }; @@ -463,6 +472,13 @@ export class Scene { 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; + } + const isTargetMoveableElement = this.moveable!.isMoveableElement(selectedTarget) || targets.some((target) => target === selectedTarget || target.contains(selectedTarget)); @@ -488,6 +504,11 @@ export class Scene { }) .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; @@ -582,6 +603,7 @@ export class Scene { return (
+ {this.connections.render()} {this.root.render()} {canShowContextMenu && ( diff --git a/public/app/plugins/panel/canvas/ConnectionAnchors.tsx b/public/app/plugins/panel/canvas/ConnectionAnchors.tsx new file mode 100644 index 00000000000..211617fe664 --- /dev/null +++ b/public/app/plugins/panel/canvas/ConnectionAnchors.tsx @@ -0,0 +1,134 @@ +import { css } from '@emotion/css'; +import React, { useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { ConnectionCoordinates } from 'app/features/canvas'; + +type Props = { + setRef: (anchorElement: HTMLDivElement) => void; + handleMouseLeave: (event: React.MouseEvent | React.FocusEvent) => void; +}; + +export const CONNECTION_ANCHOR_DIV_ID = 'connectionControl'; + +export const ConnectionAnchors = ({ setRef, handleMouseLeave }: Props) => { + const highlightEllipseRef = useRef(null); + const styles = useStyles2(getStyles); + const halfSize = 2.5; + const halfSizeHighlightEllipse = 5.5; + const anchorImage = + 'data:image/svg+xml;base64,PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSI1cHgiIGhlaWdodD0iNXB4IiB2ZXJzaW9uPSIxLjEiPjxwYXRoIGQ9Im0gMCAwIEwgNSA1IE0gMCA1IEwgNSAwIiBzdHJva2Utd2lkdGg9IjIiIHN0eWxlPSJzdHJva2Utb3BhY2l0eTowLjQiIHN0cm9rZT0iI2ZmZmZmZiIvPjxwYXRoIGQ9Im0gMCAwIEwgNSA1IE0gMCA1IEwgNSAwIiBzdHJva2U9IiMyOWI2ZjIiLz48L3N2Zz4='; + + const onMouseEnterAnchor = (event: React.MouseEvent) => { + if (!(event.target instanceof HTMLImageElement)) { + return; + } + + if (highlightEllipseRef.current && event.target.style) { + highlightEllipseRef.current.style.display = 'block'; + highlightEllipseRef.current.style.top = `calc(${event.target.style.top} - ${halfSizeHighlightEllipse}px)`; + highlightEllipseRef.current.style.left = `calc(${event.target.style.left} - ${halfSizeHighlightEllipse}px)`; + } + }; + + const onMouseLeaveHighlightElement = () => { + if (highlightEllipseRef.current) { + highlightEllipseRef.current.style.display = 'none'; + } + }; + + const connectionAnchorAlt = 'connection anchor'; + + // Unit is percentage from the middle of the element + // 0, 0 middle; -1, -1 bottom left; 1, 1 top right + const ANCHORS = [ + { x: -1, y: 1 }, + { x: -0.5, y: 1 }, + { x: 0, y: 1 }, + { x: 0.5, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 0.5 }, + { x: 1, y: 0 }, + { x: 1, y: -0.5 }, + { x: 1, y: -1 }, + { x: 0.5, y: -1 }, + { x: 0, y: -1 }, + { x: -0.5, y: -1 }, + { x: -1, y: -1 }, + { x: -1, y: -0.5 }, + { x: -1, y: 0 }, + { x: -1, y: 0.5 }, + ]; + + const generateAnchors = (anchors: ConnectionCoordinates[] = ANCHORS) => { + return anchors.map((anchor) => { + const id = `${anchor.x},${anchor.y}`; + + // Convert anchor coords to relative percentage + const style = { + top: `calc(${-anchor.y * 50 + 50}% - ${halfSize}px)`, + left: `calc(${anchor.x * 50 + 50}% - ${halfSize}px)`, + }; + + return ( + {connectionAnchorAlt} + ); + }); + }; + + return ( +
+
+
+ {generateAnchors()} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + root: css` + position: absolute; + display: none; + `, + mouseoutDiv: css` + position: absolute; + margin: -30px; + width: calc(100% + 60px); + height: calc(100% + 60px); + `, + anchor: css` + position: absolute; + cursor: cursor; + width: 5px; + height: 5px; + z-index: 100; + pointer-events: auto !important; + `, + highlightElement: css` + background-color: #00ff00; + opacity: 0.3; + position: absolute; + cursor: cursor; + position: absolute; + pointer-events: auto; + width: 16px; + height: 16px; + border-radius: 50%; + display: none; + z-index: 110; + `, +}); diff --git a/public/app/plugins/panel/canvas/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/ConnectionSVG.tsx new file mode 100644 index 00000000000..d1c1e5f7e6b --- /dev/null +++ b/public/app/plugins/panel/canvas/ConnectionSVG.tsx @@ -0,0 +1,237 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { CanvasConnection } from 'app/features/canvas/element'; +import { ElementState } from 'app/features/canvas/runtime/element'; +import { Scene } from 'app/features/canvas/runtime/scene'; + +type Props = { + setSVGRef: (anchorElement: SVGSVGElement) => void; + setLineRef: (anchorElement: SVGLineElement) => void; + scene: Scene; +}; + +interface ConnectionInfo { + source: ElementState; + target: ElementState; + info: CanvasConnection; +} + +export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { + const styles = useStyles2(getStyles); + + const CONNECTION_LINE_ID = 'connectionLineId'; + const CONNECTION_HEAD_ID = 'head'; + + const [selectedConnection, setSelectedConnection] = useState(undefined); + + // Need to use ref to ensure state is not stale in event handler + const selectedConnectionRef = useRef(selectedConnection); + useEffect(() => { + selectedConnectionRef.current = selectedConnection; + }); + + const [selectedConnectionSource, setSelectedConnectionSource] = useState(undefined); + const selectedConnectionSourceRef = useRef(selectedConnectionSource); + useEffect(() => { + selectedConnectionSourceRef.current = selectedConnectionSource; + }); + + const onKeyUp = (e: KeyboardEvent) => { + // Backspace (8) or delete (46) + if (e.keyCode === 8 || e.keyCode === 46) { + if (selectedConnectionRef.current && selectedConnectionSourceRef.current) { + selectedConnectionSourceRef.current.options.connections = + selectedConnectionSourceRef.current.options.connections?.filter( + (connection) => connection !== selectedConnectionRef.current + ); + selectedConnectionSourceRef.current.onChange(selectedConnectionSourceRef.current.options); + + setSelectedConnection(undefined); + setSelectedConnectionSource(undefined); + } + } else { + // Prevent removing event listener if key is not delete + return; + } + + document.removeEventListener('keyup', onKeyUp); + scene.selecto!.rootContainer!.removeEventListener('click', clearSelectedConnection); + }; + + const clearSelectedConnection = (event: MouseEvent) => { + const eventTarget = event.target; + + const shouldResetSelectedConnection = !( + eventTarget instanceof SVGLineElement && eventTarget.id === CONNECTION_LINE_ID + ); + + if (shouldResetSelectedConnection) { + setSelectedConnection(undefined); + setSelectedConnectionSource(undefined); + } + }; + + const selectConnection = (connection: CanvasConnection, source: ElementState) => { + if (scene.isEditingEnabled) { + setSelectedConnection(connection); + setSelectedConnectionSource(source); + + document.addEventListener('keyup', onKeyUp); + scene.selecto!.rootContainer!.addEventListener('click', clearSelectedConnection); + } + }; + + // Flat list of all connections + const findConnections = useCallback(() => { + const connections: ConnectionInfo[] = []; + for (let v of scene.byName.values()) { + if (v.options.connections) { + for (let c of v.options.connections) { + const target = c.targetName ? scene.byName.get(c.targetName) : v.parent; + if (target) { + connections.push({ + source: v, + target, + info: c, + }); + } + } + } + } + return connections; + }, [scene.byName]); + + // Figure out target and then target's relative coordinates drawing (if no target do parent) + const renderConnections = () => { + return findConnections().map((v, idx) => { + const { source, target, info } = v; + const sourceRect = source.div?.getBoundingClientRect(); + const parent = source.div?.parentElement; + const parentRect = parent?.getBoundingClientRect(); + + if (!sourceRect || !parent || !parentRect) { + return; + } + + const parentBorderWidth = parseFloat(getComputedStyle(parent).borderWidth); + + const sourceHorizontalCenter = sourceRect.left - parentRect.left - parentBorderWidth + sourceRect.width / 2; + const sourceVerticalCenter = sourceRect.top - parentRect.top - parentBorderWidth + sourceRect.height / 2; + + // Convert from connection coords to DOM coords + // TODO: Break this out into util function and add tests + const x1 = sourceHorizontalCenter + (info.source.x * sourceRect.width) / 2; + const y1 = sourceVerticalCenter - (info.source.y * sourceRect.height) / 2; + + let x2; + let y2; + + if (info.targetName) { + const targetRect = target.div?.getBoundingClientRect(); + + const targetHorizontalCenter = targetRect!.left - parentRect.left - parentBorderWidth + targetRect!.width / 2; + const targetVerticalCenter = targetRect!.top - parentRect.top - parentBorderWidth + targetRect!.height / 2; + + x2 = targetHorizontalCenter + (info.target.x * targetRect!.width) / 2; + y2 = targetVerticalCenter - (info.target.y * targetRect!.height) / 2; + } else { + const parentHorizontalCenter = parentRect.width / 2; + const parentVerticalCenter = parentRect.height / 2; + + x2 = parentHorizontalCenter + (info.target.x * parentRect.width) / 2; + y2 = parentVerticalCenter - (info.target.y * parentRect.height) / 2; + } + + const isSelected = selectedConnection === info; + const selectedStyles = { stroke: '#44aaff', strokeWidth: 3 }; + const connectionCursorStyle = scene.isEditingEnabled ? 'grab' : ''; + + return ( + + selectConnection(info, source)}> + + + + + + + + + + ); + }); + }; + + return ( + <> + + + + + + + + + {renderConnections()} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + editorSVG: css` + position: absolute; + pointer-events: none; + width: 100%; + height: 100%; + z-index: 1000; + display: none; + `, + connection: css` + position: absolute; + width: 100%; + height: 100%; + z-index: 1000; + pointer-events: none; + `, +}); diff --git a/public/app/plugins/panel/canvas/Connections.tsx b/public/app/plugins/panel/canvas/Connections.tsx new file mode 100644 index 00000000000..b3c46fbbee2 --- /dev/null +++ b/public/app/plugins/panel/canvas/Connections.tsx @@ -0,0 +1,200 @@ +import React from 'react'; + +import { ConnectionPath } from 'app/features/canvas'; +import { ElementState } from 'app/features/canvas/runtime/element'; +import { Scene } from 'app/features/canvas/runtime/scene'; + +import { ConnectionAnchors } from './ConnectionAnchors'; +import { ConnectionSVG } from './ConnectionSVG'; + +export class Connections { + scene: Scene; + connectionAnchorDiv?: HTMLDivElement; + connectionSVG?: SVGElement; + connectionLine?: SVGLineElement; + connectionSource?: ElementState; + connectionTarget?: ElementState; + isDrawingConnection?: boolean; + + constructor(scene: Scene) { + this.scene = scene; + } + + setConnectionAnchorRef = (anchorElement: HTMLDivElement) => { + this.connectionAnchorDiv = anchorElement; + }; + + setConnectionSVGRef = (connectionSVG: SVGSVGElement) => { + this.connectionSVG = connectionSVG; + }; + + setConnectionLineRef = (connectionLine: SVGLineElement) => { + this.connectionLine = connectionLine; + }; + + handleMouseEnter = (event: React.MouseEvent) => { + if (!(event.target instanceof HTMLElement) || !this.scene.isEditingEnabled) { + return; + } + + const element = event.target.parentElement?.parentElement; + if (!element) { + console.log('no element'); + return; + } + + if (this.isDrawingConnection) { + this.connectionTarget = this.scene.findElementByTarget(element); + } else { + this.connectionSource = this.scene.findElementByTarget(element); + if (!this.connectionSource) { + console.log('no connection source'); + return; + } + } + + const elementBoundingRect = element!.getBoundingClientRect(); + const parentBoundingRect = this.scene.div?.getBoundingClientRect(); + let parentBorderWidth = parseFloat(getComputedStyle(this.scene.div!).borderWidth); + + const relativeTop = elementBoundingRect.top - (parentBoundingRect?.top ?? 0) - parentBorderWidth; + const relativeLeft = elementBoundingRect.left - (parentBoundingRect?.left ?? 0) - parentBorderWidth; + + if (this.connectionAnchorDiv) { + this.connectionAnchorDiv.style.display = 'none'; + this.connectionAnchorDiv.style.display = 'block'; + this.connectionAnchorDiv.style.top = `${relativeTop}px`; + this.connectionAnchorDiv.style.left = `${relativeLeft}px`; + this.connectionAnchorDiv.style.height = `${elementBoundingRect.height}px`; + this.connectionAnchorDiv.style.width = `${elementBoundingRect.width}px`; + } + }; + + handleMouseLeave = (event: React.MouseEvent | React.FocusEvent) => { + this.connectionAnchorDiv!.style.display = 'none'; + }; + + connectionListener = (event: MouseEvent) => { + event.preventDefault(); + + if (!(this.connectionLine && this.scene.div && this.scene.div.parentElement)) { + return; + } + + const parentBoundingRect = this.scene.div.parentElement.getBoundingClientRect(); + const x = event.pageX - parentBoundingRect.x; + const y = event.pageY - parentBoundingRect.y; + + this.connectionLine.setAttribute('x2', `${x}`); + this.connectionLine.setAttribute('y2', `${y}`); + + if (!event.buttons) { + if (this.connectionSource && this.connectionSource.div && this.connectionSource.div.parentElement) { + const connectionLineX1 = this.connectionLine.x1.baseVal.value; + const connectionLineY1 = this.connectionLine.y1.baseVal.value; + + const sourceRect = this.connectionSource.div.getBoundingClientRect(); + const parentRect = this.connectionSource.div.parentElement.getBoundingClientRect(); + const parentBorderWidth = parseFloat(getComputedStyle(this.connectionSource.div.parentElement).borderWidth); + + const sourceVerticalCenter = sourceRect.top - parentRect.top - parentBorderWidth + sourceRect.height / 2; + const sourceHorizontalCenter = sourceRect.left - parentRect.left - parentBorderWidth + sourceRect.width / 2; + + // Convert from DOM coords to connection coords + // TODO: Break this out into util function and add tests + const sourceX = (connectionLineX1 - sourceHorizontalCenter) / (sourceRect.width / 2); + const sourceY = (sourceVerticalCenter - connectionLineY1) / (sourceRect.height / 2); + + let targetX; + let targetY; + let targetName; + + if (this.connectionTarget && this.connectionTarget.div) { + const targetRect = this.connectionTarget.div.getBoundingClientRect(); + + const targetVerticalCenter = targetRect.top - parentRect.top - parentBorderWidth + targetRect.height / 2; + const targetHorizontalCenter = targetRect.left - parentRect.left - parentBorderWidth + targetRect.width / 2; + + targetX = (x - targetHorizontalCenter) / (targetRect.width / 2); + targetY = (targetVerticalCenter - y) / (targetRect.height / 2); + targetName = this.connectionTarget.options.name; + } else { + const parentVerticalCenter = parentRect.height / 2; + const parentHorizontalCenter = parentRect.width / 2; + + targetX = (x - parentHorizontalCenter) / (parentRect.width / 2); + targetY = (parentVerticalCenter - y) / (parentRect.height / 2); + } + + const connection = { + source: { + x: sourceX, + y: sourceY, + }, + target: { + x: targetX, + y: targetY, + }, + targetName: targetName, + color: 'white', + size: 10, + path: ConnectionPath.Straight, + }; + + const { options } = this.connectionSource; + if (!options.connections) { + options.connections = []; + } + this.connectionSource.options.connections = [...options.connections, connection]; + + this.connectionSource.onChange(this.connectionSource.options); + } + + if (this.connectionSVG) { + this.connectionSVG.style.display = 'none'; + } + + if (this.scene.selecto && this.scene.selecto.rootContainer) { + this.scene.selecto.rootContainer.style.cursor = 'default'; + this.scene.selecto.rootContainer.removeEventListener('mousemove', this.connectionListener); + } + + this.isDrawingConnection = false; + } + }; + + handleConnectionDragStart = (selectedTarget: HTMLElement, clientX: number, clientY: number) => { + this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; + if (this.connectionSVG && this.connectionLine && this.scene.div && this.scene.div.parentElement) { + const connectionStartTargetBox = selectedTarget.getBoundingClientRect(); + const parentBoundingRect = this.scene.div.parentElement.getBoundingClientRect(); + + // TODO: Make this not as magic numbery -> related to the height / width of highlight ellipse + const connectionAnchorHighlightOffset = 8; + const x = connectionStartTargetBox.x - parentBoundingRect.x + connectionAnchorHighlightOffset; + const y = connectionStartTargetBox.y - parentBoundingRect.y + connectionAnchorHighlightOffset; + + const mouseX = clientX - parentBoundingRect.x; + const mouseY = clientY - parentBoundingRect.y; + + this.connectionLine.setAttribute('x1', `${x}`); + this.connectionLine.setAttribute('y1', `${y}`); + this.connectionLine.setAttribute('x2', `${mouseX}`); + this.connectionLine.setAttribute('y2', `${mouseY}`); + this.connectionSVG.style.display = 'block'; + + this.isDrawingConnection = true; + } + + this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener); + }; + + render() { + return ( + <> + + + + ); + } +}