Canvas: Add support for basic arrows (#57561)

Co-authored-by: Ryan McKinley <ryan.mckinley@grafana.com>
Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
Co-authored-by: Drew Slobodnjak <drew.slobodnjak@grafana.com>
This commit is contained in:
Nathan Marrs 2023-01-12 19:38:00 -07:00 committed by GitHub
parent 0c20fe0ac9
commit b1a24232e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 625 additions and 1 deletions

View File

@ -27,6 +27,26 @@ export interface CanvasElementOptions<TConfig = any> {
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<TConfig = any, TData = any> {

View File

@ -453,7 +453,11 @@ export class ElementState implements LayerElement {
const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div);
return (
<div key={this.UID} ref={this.initElement}>
<div
key={this.UID}
ref={this.initElement}
onMouseEnter={!isSelected ? scene?.connections.handleMouseEnter : undefined}
>
<item.display
key={`${this.UID}/${this.revId}`}
config={this.options.config}

View File

@ -203,6 +203,9 @@ export class FrameState extends ElementState {
opts.placement = placement;
}
// Clear connections on duplicate
opts.connections = undefined;
const copy = new ElementState(element.item, opts, this);
copy.updateData(this.scene.context);
if (updateName) {
@ -210,6 +213,10 @@ export class FrameState extends ElementState {
}
this.elements.push(copy);
this.scene.byName.set(copy.options.name, copy);
// Update scene byName map for original element (to avoid stale references (e.g. for connections))
this.scene.byName.set(element.options.name, element);
this.scene.save();
this.reinitializeMoveable();

View File

@ -26,6 +26,8 @@ import {
getTextDimensionFromData,
} from 'app/features/dimensions/utils';
import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/ConnectionAnchors';
import { Connections } from 'app/plugins/panel/canvas/Connections';
import { AnchorPoint, LayerActionID } from 'app/plugins/panel/canvas/types';
import appEvents from '../../../core/app_events';
@ -59,6 +61,7 @@ export class Scene {
selecto?: Selecto;
moveable?: Moveable;
div?: HTMLDivElement;
connections: Connections;
currentLayer?: FrameState;
isEditingEnabled?: boolean;
shouldShowAdvancedTypes?: boolean;
@ -93,6 +96,7 @@ export class Scene {
});
this.panel = panel;
this.connections = new Connections(this);
}
getNextElementName = (isFrame = false) => {
@ -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 (
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
{this.connections.render()}
{this.root.render()}
{canShowContextMenu && (
<Portal>

View File

@ -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<Element, MouseEvent> | React.FocusEvent<HTMLDivElement, Element>) => void;
};
export const CONNECTION_ANCHOR_DIV_ID = 'connectionControl';
export const ConnectionAnchors = ({ setRef, handleMouseLeave }: Props) => {
const highlightEllipseRef = useRef<HTMLDivElement>(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 (
<img
id={id}
key={id}
alt={connectionAnchorAlt}
className={styles.anchor}
style={style}
src={anchorImage}
onMouseEnter={onMouseEnterAnchor}
/>
);
});
};
return (
<div className={styles.root} ref={setRef}>
<div className={styles.mouseoutDiv} onMouseOut={handleMouseLeave} onBlur={handleMouseLeave} />
<div
id={CONNECTION_ANCHOR_DIV_ID}
ref={highlightEllipseRef}
className={styles.highlightElement}
onMouseLeave={onMouseLeaveHighlightElement}
/>
{generateAnchors()}
</div>
);
};
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;
`,
});

View File

@ -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<CanvasConnection | undefined>(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<ElementState | undefined>(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 (
<svg className={styles.connection} key={idx}>
<g onClick={() => selectConnection(info, source)}>
<defs>
<marker
id={CONNECTION_HEAD_ID}
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
stroke="rgb(255,255,255)"
>
<polygon points="0 0, 10 3.5, 0 7" fill="rgb(255,255,255)" />
</marker>
</defs>
<line
id={`${CONNECTION_LINE_ID}_transparent`}
cursor={connectionCursorStyle}
stroke="transparent"
pointerEvents="auto"
strokeWidth={15}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
/>
<line
id={CONNECTION_LINE_ID}
stroke="rgb(255,255,255)"
pointerEvents="auto"
strokeWidth={2}
markerEnd={`url(#${CONNECTION_HEAD_ID})`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
style={isSelected ? selectedStyles : {}}
cursor={connectionCursorStyle}
/>
</g>
</svg>
);
});
};
return (
<>
<svg ref={setSVGRef} className={styles.editorSVG}>
<defs>
<marker
id="editorHead"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
stroke="rgb(255,255,255)"
>
<polygon points="0 0, 10 3.5, 0 7" fill="rgb(255,255,255)" />
</marker>
</defs>
<line ref={setLineRef} stroke="rgb(255,255,255)" strokeWidth={2} markerEnd="url(#editorHead)" />
</svg>
{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;
`,
});

View File

@ -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 (
<>
<ConnectionAnchors setRef={this.setConnectionAnchorRef} handleMouseLeave={this.handleMouseLeave} />
<ConnectionSVG setSVGRef={this.setConnectionSVGRef} setLineRef={this.setConnectionLineRef} scene={this.scene} />
</>
);
}
}