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;
|
||||
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> {
|
||||
|
@ -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}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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>
|
||||
|
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