mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
0c20fe0ac9
commit
b1a24232e4
@ -27,6 +27,26 @@ export interface CanvasElementOptions<TConfig = any> {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
background?: BackgroundConfig;
|
background?: BackgroundConfig;
|
||||||
border?: LineConfig;
|
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> {
|
export interface CanvasElementProps<TConfig = any, TData = any> {
|
||||||
|
@ -453,7 +453,11 @@ export class ElementState implements LayerElement {
|
|||||||
const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div);
|
const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={this.UID} ref={this.initElement}>
|
<div
|
||||||
|
key={this.UID}
|
||||||
|
ref={this.initElement}
|
||||||
|
onMouseEnter={!isSelected ? scene?.connections.handleMouseEnter : undefined}
|
||||||
|
>
|
||||||
<item.display
|
<item.display
|
||||||
key={`${this.UID}/${this.revId}`}
|
key={`${this.UID}/${this.revId}`}
|
||||||
config={this.options.config}
|
config={this.options.config}
|
||||||
|
@ -203,6 +203,9 @@ export class FrameState extends ElementState {
|
|||||||
opts.placement = placement;
|
opts.placement = placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear connections on duplicate
|
||||||
|
opts.connections = undefined;
|
||||||
|
|
||||||
const copy = new ElementState(element.item, opts, this);
|
const copy = new ElementState(element.item, opts, this);
|
||||||
copy.updateData(this.scene.context);
|
copy.updateData(this.scene.context);
|
||||||
if (updateName) {
|
if (updateName) {
|
||||||
@ -210,6 +213,10 @@ export class FrameState extends ElementState {
|
|||||||
}
|
}
|
||||||
this.elements.push(copy);
|
this.elements.push(copy);
|
||||||
this.scene.byName.set(copy.options.name, 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.scene.save();
|
||||||
this.reinitializeMoveable();
|
this.reinitializeMoveable();
|
||||||
|
|
||||||
|
@ -26,6 +26,8 @@ import {
|
|||||||
getTextDimensionFromData,
|
getTextDimensionFromData,
|
||||||
} from 'app/features/dimensions/utils';
|
} from 'app/features/dimensions/utils';
|
||||||
import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
|
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 { AnchorPoint, LayerActionID } from 'app/plugins/panel/canvas/types';
|
||||||
|
|
||||||
import appEvents from '../../../core/app_events';
|
import appEvents from '../../../core/app_events';
|
||||||
@ -59,6 +61,7 @@ export class Scene {
|
|||||||
selecto?: Selecto;
|
selecto?: Selecto;
|
||||||
moveable?: Moveable;
|
moveable?: Moveable;
|
||||||
div?: HTMLDivElement;
|
div?: HTMLDivElement;
|
||||||
|
connections: Connections;
|
||||||
currentLayer?: FrameState;
|
currentLayer?: FrameState;
|
||||||
isEditingEnabled?: boolean;
|
isEditingEnabled?: boolean;
|
||||||
shouldShowAdvancedTypes?: boolean;
|
shouldShowAdvancedTypes?: boolean;
|
||||||
@ -93,6 +96,7 @@ export class Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.panel = panel;
|
this.panel = panel;
|
||||||
|
this.connections = new Connections(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
getNextElementName = (isFrame = false) => {
|
getNextElementName = (isFrame = false) => {
|
||||||
@ -304,6 +308,11 @@ export class Scene {
|
|||||||
this.selecto.setSelectedTargets(selection.targets);
|
this.selecto.setSelectedTargets(selection.targets);
|
||||||
this.updateSelection(selection);
|
this.updateSelection(selection);
|
||||||
this.editModeEnabled.next(false);
|
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) => {
|
this.selecto!.on('dragStart', (event) => {
|
||||||
const selectedTarget = event.inputEvent.target;
|
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 =
|
const isTargetMoveableElement =
|
||||||
this.moveable!.isMoveableElement(selectedTarget) ||
|
this.moveable!.isMoveableElement(selectedTarget) ||
|
||||||
targets.some((target) => target === selectedTarget || target.contains(selectedTarget));
|
targets.some((target) => target === selectedTarget || target.contains(selectedTarget));
|
||||||
@ -488,6 +504,11 @@ export class Scene {
|
|||||||
})
|
})
|
||||||
.on('select', () => {
|
.on('select', () => {
|
||||||
this.editModeEnabled.next(false);
|
this.editModeEnabled.next(false);
|
||||||
|
|
||||||
|
// Hide connection anchors on select
|
||||||
|
if (this.connections.connectionAnchorDiv) {
|
||||||
|
this.connections.connectionAnchorDiv.style.display = 'none';
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.on('selectEnd', (event) => {
|
.on('selectEnd', (event) => {
|
||||||
targets = event.selected;
|
targets = event.selected;
|
||||||
@ -582,6 +603,7 @@ export class Scene {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
|
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
|
||||||
|
{this.connections.render()}
|
||||||
{this.root.render()}
|
{this.root.render()}
|
||||||
{canShowContextMenu && (
|
{canShowContextMenu && (
|
||||||
<Portal>
|
<Portal>
|
||||||
|
134
public/app/plugins/panel/canvas/ConnectionAnchors.tsx
Normal file
134
public/app/plugins/panel/canvas/ConnectionAnchors.tsx
Normal 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;
|
||||||
|
`,
|
||||||
|
});
|
237
public/app/plugins/panel/canvas/ConnectionSVG.tsx
Normal file
237
public/app/plugins/panel/canvas/ConnectionSVG.tsx
Normal 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;
|
||||||
|
`,
|
||||||
|
});
|
200
public/app/plugins/panel/canvas/Connections.tsx
Normal file
200
public/app/plugins/panel/canvas/Connections.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user