Canvas: Pan and zoom improvement (#86879)

* fix(canvas): style linter issue

* feat(canvas): update infinite viewer root element

* fix(canvas): resize element on moving

* fix(canvas): global element position

* fix(canvas): connection anchor position

* fix(canvas): connection position

* fix(canvas): connection coordinates calculation

* cleaning

* fix(canvas): calculation connection coordinates

* fix(canvas): initial connections svg position

* fix(canvas): align connection with new coordinates system

* fix(canvas): temporary element position fix

* comment

* Canvas: Fix canvas selecto (#104621)

* fix(canvas): get context back

* fix(canvas): selecto containers

* clean up

* Canvas: Fix jumpy elements behaviour on select and drag (#104641)

fix(canvas): jumpy elements behaviour on select and drag

* fix(canvas): connection regression (#104682)

* Canvas pan + zoom:  Updated mouse interactions (#104648)

* feat: canvas panel pan + zoom key binds

* feat: panning when right click + ctrl

* chore: some cleanup

* chore: adjust mouse move delta by canvas scale

* Canvas: Zoom to content (#104950)

feat(canvas): zoom to content

* Canvas pan + zoom: Unique key for canvas panel elements (#104947)

fix: unique keys

* Canvas: Placement adjustment (#105117)

* feat(canvas): top/left placement migration

* feat(canvas): adjust constraint system for pan and zoom usage

* feat(canvas): support other constraints

* Canvas: Align connection anchors and element rotation (#106002)

* feat(canvas): align connection anchors and element rotation

* clean up

* clean-up math

* Canvas: Disable pan when pan+zoom toggle is false (#106224)

chore: no pan when pan+zoom toggle is disabled

* Canvas: Fix desync between scene and InfiniteViewer zoom/position on element addition (#106655)

feat(canvas): keep pan and zoom state to re-apply

* Canvas: Clicking on metricValue dropdown does not work; right-click triggers context menu instead (#106701)

fix(canvas): metricValue left click dropdown

* Canvas: Double-click on canvas should reset scale and position (#106709)

fix(canvas): dblclick to reset zoom

* Canvas: Put new canvas pan and zoom under feature toggle (#107001)

* feat(canvas): put pan and zoom under FF

* clean-up

* clean-up

* fix(canvas): clear selection on scene manipulation

* fix(canvas): any types

* Canvas: Fix canvas apply styles (#107404)

* fix(canvas): apply styles

* remove deps

* Canvas: E2E test coverage (#107474)

* betterer

* feat(canvas): sceneAbleManagement tests

* feat(canvas): playwright tests

* chore(canvas): add draft e2e tests

* chore(canvas): clean draft e2e tests

* chore(canvas): fix draft e2e tests

* chore(canvas): todo comments

* chore(canvas): update e2e tests

* chore(canvas): delete scene unit tests

* chore(canvas): delete sceneAbleManagement unit tests

* chore(canvas): delete sceneAbleManagement unit tests

* chore(canvas): linter

* chore(canvas): locales

* chore(canvas): remove flag checking

* Fix canvas connection point z-indexing bug (#107223)

fix(canvas): hoist connect points one level higher in DOM to fix z-index

* Revert "Fix canvas connection point z-indexing bug" (#108146)

Revert "Fix canvas connection point z-indexing bug (#107223)"

This reverts commit e419cb164a.

* Canvas: Set canvas zoom range (#108318)

* chore(canvas): set zoom range

* chore(canvas): naming

* Canvas: Fix infinite-viewer connections viewport values (#108315)

chore(canvas): infinite-viewer connections viewport values

* Canvas: Fix z-indices order and explicit pointer events (#108284)

* fix(canvas-connection-points): z-indices order, explicit pointer events

* chore(canvas): remove user select

* chore(canvas): naming

* chore(canvas): create ConnectionAnchor2 component

---------

Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com>

* Canvas: Hide anchors on panel resize (#108588)

chore(canvas): hide anchors on panel resize

* Canvas: Center & scale select, resize, zoom bugs (#108749)

* Canvas: Fix center and scale constraint with zoom

* Remove unused resize flag

* Consolidate and clean up

* fix(canvas): re-size scale and center elements

---------

Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com>

* Canvas: Fix on constraint change during zoom (#108947)

* Canvas: Ensure correct panel size during edit (#108953)

* chore(canvas): re-gen cue

---------

Co-authored-by: drew08t <drew08@gmail.com>
Co-authored-by: Sven Grossmann <svennergr@gmail.com>
Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
Co-authored-by: Jesse David Peterson <jesdavpet@users.noreply.github.com>
Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com>
This commit is contained in:
Ihor Yeromin
2025-07-31 19:34:48 +02:00
committed by GitHub
co-authored by drew08t Sven Grossmann Alex Spencer Jesse David Peterson Drew Slobodnjak
parent 400aeccb35
commit 66b01e49a1
23 changed files with 2353 additions and 250 deletions
@@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of DashboardStatus
func (s *DashboardStatus) DeepCopy() *DashboardStatus {
cpy := &DashboardStatus{}
@@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of DashboardStatus
func (s *DashboardStatus) DeepCopy() *DashboardStatus {
cpy := &DashboardStatus{}
@@ -0,0 +1,87 @@
import { Locator } from '@playwright/test';
import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
canvasPanelPanZoom: true,
},
});
test.describe('Canvas Panel - Scene Tests', () => {
test.beforeEach(async ({ page, gotoDashboardPage }) => {
const dashboardPage = await gotoDashboardPage({});
const panelEditPage = await dashboardPage.addPanel();
await panelEditPage.setVisualization('Canvas');
// Wait for canvas panel to load
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
});
test('should create and render canvas panel with scene elements', async ({ page }) => {
const canvasElement = await page.getByRole('button', { name: 'Double click to set field' });
await expect(canvasElement).toBeVisible();
});
test('should handle scene pan and zoom when enabled', async ({ page }) => {
// Feature toggle is enabled, pan/zoom functionality should be available
const panZoomCheckbox = await page.getByLabel('Canvas Pan and zoom field').locator('label').nth(1);
await panZoomCheckbox.setChecked(true);
await expect(panZoomCheckbox).toBeChecked({ checked: true });
const canvasElement = await page.getByRole('button', { name: 'Double click to set field' });
const canvasSceneWrapper = await page.getByTestId('canvas-scene-wrapper');
// Check if infinite viewer is present (pan/zoom feature)
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
const infiniteViewer = page.locator('[data-testid="canvas-scene-pan-zoom"]');
await infiniteViewer.waitFor({ state: 'visible', timeout: 5000 });
await expect(await infiniteViewer.isVisible()).toBe(true);
await infiniteViewer.hover();
const viewerBounds = await infiniteViewer.boundingBox();
await expect(viewerBounds).toBeDefined();
// Test pan functionality
const startX = viewerBounds.x + 50;
const startY = viewerBounds.y + 50;
const endX = viewerBounds.x + 250;
const endY = viewerBounds.y + 250;
await page.getByTestId('canvas-scene-pan-zoom');
await page.mouse.move(startX, startY);
await page.mouse.down({ button: 'middle' });
await page.mouse.move(endX, endY);
await page.mouse.up({ button: 'middle' });
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(true);
// Test zoom reset with double-click
await page.mouse.dblclick(startX, startY);
// Verify canvas element is visible after pan/zoom operations
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(false);
// Test zoom functionality
await page.mouse.move(startX, startY);
await page.keyboard.down('Control');
await page.mouse.wheel(0, -400); // Zoom in
await page.keyboard.up('Control');
// Check if canvas element is not visible after zoom operations
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(true);
// Test zoom reset with double-click
await page.mouse.dblclick(startX, startY);
// Verify canvas element is visible after pan/zoom operations
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(false);
});
});
// TODO: this function is workaround for .toBeVisible()
async function isOutsideViewport(element: Locator, viewPort: Locator): Promise<boolean> {
const elementBounds = await element.boundingBox();
const viewportBounds = await viewPort.boundingBox();
return (
elementBounds.x + elementBounds.width < viewportBounds.x ||
elementBounds.x > viewportBounds.x + viewportBounds.width ||
elementBounds.y + elementBounds.height < viewportBounds.y ||
elementBounds.y > viewportBounds.y + viewportBounds.height
);
}
+1 -1
View File
@@ -347,6 +347,7 @@
"i18next-pseudo": "^2.2.1",
"immer": "10.1.1",
"immutable": "5.1.3",
"infinite-viewer": "^0.29.1",
"ix": "^7.0.0",
"jquery": "3.7.1",
"js-yaml": "^4.1.0",
@@ -406,7 +407,6 @@
"react-virtualized-auto-sizer": "1.0.26",
"react-window": "1.8.11",
"react-window-infinite-loader": "1.0.10",
"react-zoom-pan-pinch": "^3.3.0",
"reduce-reducers": "^1.0.4",
"redux": "5.0.1",
"redux-thunk": "3.1.0",
@@ -117,10 +117,6 @@ export interface CanvasTooltip {
}
export interface Options {
/**
* Enable infinite pan
*/
infinitePan: boolean;
/**
* Enable inline editing
*/
@@ -155,11 +151,15 @@ export interface Options {
* Controls tooltip options
*/
tooltip: CanvasTooltip;
/**
* Zoom to content
*/
zoomToContent: boolean;
}
export const defaultOptions: Partial<Options> = {
infinitePan: true,
inlineEditing: true,
panZoom: true,
showAdvancedTypes: true,
zoomToContent: true,
};
+9
View File
@@ -195,6 +195,15 @@ export default defineConfig<PluginOptions>({
},
dependencies: ['authenticate'],
},
{
name: 'canvas',
testDir: path.join(testDirRoot, '/canvas'),
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['authenticate'],
},
{
name: 'zipkin',
testDir: path.join(pluginDirRoot, '/zipkin'),
@@ -1,114 +0,0 @@
import * as React from 'react';
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import { config } from '@grafana/runtime';
import { Scene } from './scene';
type SceneTransformWrapperProps = {
scene: Scene;
children: React.ReactNode;
};
export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransformWrapperProps) => {
const onZoom = (zoomPanPinchRef: ReactZoomPanPinchRef) => {
const scale = zoomPanPinchRef.state.scale;
scene.scale = scale;
if (scene.shouldInfinitePan) {
const isScaleZoomedOut = scale < 1;
if (isScaleZoomedOut) {
scene.updateSize(scene.width / scale, scene.height / scale);
scene.panel.forceUpdate();
}
}
};
const onZoomStop = (zoomPanPinchRef: ReactZoomPanPinchRef) => {
const scale = zoomPanPinchRef.state.scale;
scene.scale = scale;
updateMoveable(scale);
};
const onTransformed = (
_: ReactZoomPanPinchRef,
state: {
scale: number;
positionX: number;
positionY: number;
}
) => {
const scale = state.scale;
scene.scale = scale;
updateMoveable(scale);
};
const updateMoveable = (scale: number) => {
if (scene.moveable && scale > 0) {
scene.moveable.zoom = 1 / scale;
if (scale === 1) {
scene.moveable.snappable = true;
} else {
scene.moveable.snappable = false;
}
}
};
const onPanning = (_: ReactZoomPanPinchRef, event: MouseEvent | TouchEvent) => {
if (scene.shouldInfinitePan && event instanceof MouseEvent) {
// Get deltaX and deltaY from pan event and add it to current canvas dimensions
let deltaX = event.movementX;
let deltaY = event.movementY;
if (deltaX > 0) {
deltaX = 0;
}
if (deltaY > 0) {
deltaY = 0;
}
// TODO: Consider bounding to the scene elements instead of allowing "infinite" panning
// TODO: Consider making scene grow in all directions vs just down to the right / bottom
scene.updateSize(scene.width - deltaX, scene.height - deltaY);
scene.panel.forceUpdate();
}
};
const onSceneContainerMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
// If pan and zoom is disabled or context menu is visible, don't pan
if ((!scene.shouldPanZoom || scene.contextMenuVisible) && (e.button === 1 || (e.button === 2 && e.ctrlKey))) {
e.preventDefault();
e.stopPropagation();
}
// If context menu is hidden, ignore left mouse or non-ctrl right mouse for pan
if (!scene.contextMenuVisible && !scene.isPanelEditing && e.button === 2 && !e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
}
};
// Set panel content overflow to hidden to prevent canvas content from overflowing
scene.div?.parentElement?.parentElement?.parentElement?.parentElement?.setAttribute('style', `overflow: hidden`);
return (
<TransformWrapper
doubleClick={{ mode: 'reset' }}
ref={scene.transformComponentRef}
onZoom={onZoom}
onZoomStop={onZoomStop}
onTransformed={onTransformed}
disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom}
panning={{ allowLeftClickPan: false }}
limitToBounds={!scene.shouldInfinitePan}
minScale={scene.shouldInfinitePan ? 0.1 : undefined}
onPanning={onPanning}
>
<TransformComponent>
{/* The <div> element has child elements that allow for mouse events, so we need to disable the linter rule */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={onSceneContainerMouseDown}>{sceneDiv}</div>
</TransformComponent>
</TransformWrapper>
);
};
+265 -18
View File
@@ -16,6 +16,7 @@ import { t } from '@grafana/i18n';
import { TooltipDisplayMode } from '@grafana/schema';
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { config } from 'app/core/config';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions/context';
import {
@@ -25,7 +26,13 @@ import {
Placement,
VerticalConstraint,
} from 'app/plugins/panel/canvas/panelcfg.gen';
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
import {
applyStyles,
getConnectionsByTarget,
getRowIndex,
isConnectionTarget,
removeStyles,
} from 'app/plugins/panel/canvas/utils';
import { getActions, getActionsDefaultField } from '../../actions/utils';
import { CanvasElementItem, CanvasElementOptions } from '../element';
@@ -114,6 +121,10 @@ export class ElementState implements LayerElement {
/** Use the configured options to update CSS style properties directly on the wrapper div **/
applyLayoutStylesToDiv(disablePointerEvents?: boolean) {
if (config.featureToggles.canvasPanelPanZoom) {
this.applyLayoutStylesToDiv2(disablePointerEvents);
return;
}
if (this.isRoot()) {
// Root supersedes layout engine and is always 100% width + height of panel
return;
@@ -224,34 +235,166 @@ export class ElementState implements LayerElement {
this.sizeStyle = style;
if (this.div) {
for (const key in this.sizeStyle) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
this.div.style[key as any] = (this.sizeStyle as any)[key];
}
applyStyles(this.sizeStyle, this.div);
// TODO: This is a hack, we should have a better way to handle this
const elementType = this.options.type;
if (!SVGElements.has(elementType)) {
// apply styles to div if it's not an SVG element
for (const key in this.dataStyle) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
this.div.style[key as any] = (this.dataStyle as any)[key];
}
applyStyles(this.dataStyle, this.div);
} else {
// ELEMENT IS SVG
// clean data styles from div if it's an SVG element; SVG elements have their own data styles;
// this is necessary for changing type of element cases;
// wrapper div element (this.div) doesn't re-render (has static `key` property),
// so we have to clean styles manually;
for (const key in this.dataStyle) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
this.div.style[key as any] = '';
}
removeStyles(this.dataStyle, this.div);
}
}
}
/** Use the configured options to update CSS style properties directly on the wrapper div **/
applyLayoutStylesToDiv2(disablePointerEvents?: boolean) {
if (this.isRoot()) {
// Root supersedes layout engine and is always 100% width + height of panel
return;
}
const scene = this.getScene();
const { width: sceneWidth, height: sceneHeight } = scene ?? {};
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
const placement: Placement = this.options.placement ?? {};
const editingEnabled = scene?.isEditingEnabled;
const style: React.CSSProperties = {
cursor: editingEnabled ? 'grab' : 'auto',
pointerEvents: disablePointerEvents ? 'none' : 'auto',
position: 'absolute',
// Minimum element size is 10x10
minWidth: '10px',
minHeight: '10px',
};
let transformY = '0px';
let transformX = '0px';
switch (vertical) {
case VerticalConstraint.Top:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
transformY = `${placement.top ?? 0}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
break;
case VerticalConstraint.Bottom:
placement.bottom = placement.bottom ?? 0;
placement.height = placement.height ?? 100;
transformY = `${sceneHeight! - (placement.bottom ?? 0) - (placement.height ?? 100)}px`;
style.height = `${placement.height}px`;
delete placement.top;
break;
case VerticalConstraint.TopBottom:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
transformY = `${placement.top ?? 0}px`;
style.height = `${sceneHeight! - (placement.top ?? 0) - (placement.bottom ?? 0)}px`;
delete placement.height;
break;
case VerticalConstraint.Center:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
transformY = `${sceneHeight! / 2 - (placement.top ?? 0) - (placement.height ?? 0) / 2}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
break;
case VerticalConstraint.Scale:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
transformY = `${(placement.top ?? 0) * (sceneHeight! / 100)}px`;
style.height = `${sceneHeight! - (placement.top ?? 0) * (sceneHeight! / 100) - (placement.bottom ?? 0) * (sceneHeight! / 100)}px`;
delete placement.height;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
transformX = `${placement.left ?? 0}px`;
style.width = `${placement.width}px`;
delete placement.right;
break;
case HorizontalConstraint.Right:
placement.right = placement.right ?? 0;
placement.width = placement.width ?? 100;
transformX = `${sceneWidth! - (placement.right ?? 0) - (placement.width ?? 100)}px`;
style.width = `${placement.width}px`;
delete placement.left;
break;
case HorizontalConstraint.LeftRight:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
transformX = `${placement.left ?? 0}px`;
style.width = `${sceneWidth! - (placement.left ?? 0) - (placement.right ?? 0)}px`;
delete placement.width;
break;
case HorizontalConstraint.Center:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
transformX = `${sceneWidth! / 2 - (placement.left ?? 0) - (placement.width ?? 0) / 2}px`;
style.width = `${placement.width}px`;
delete placement.right;
break;
case HorizontalConstraint.Scale:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
transformX = `${(placement.left ?? 0) * (sceneWidth! / 100)}px`;
style.width = `${sceneWidth! - (placement.left ?? 0) * (sceneWidth! / 100) - (placement.right ?? 0) * (sceneWidth! / 100)}px`;
delete placement.width;
break;
}
this.options.placement = placement;
style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`;
this.sizeStyle = style;
if (this.div) {
applyStyles(this.sizeStyle, this.div);
// TODO: This is a hack, we should have a better way to handle this
const elementType = this.options.type;
if (!SVGElements.has(elementType)) {
// apply styles to div if it's not an SVG element
applyStyles(this.dataStyle, this.div);
} else {
// ELEMENT IS SVG
// clean data styles from div if it's an SVG element; SVG elements have their own data styles;
// this is necessary for changing type of element cases;
// wrapper div element (this.div) doesn't re-render (has static `key` property),
// so we have to clean styles manually;
removeStyles(this.dataStyle, this.div);
}
}
}
getTopLeftValues(element: Element) {
const style = window.getComputedStyle(element);
const matrix = new DOMMatrix(style.transform || '');
return {
left: matrix.m41,
top: matrix.m42,
width: style.width ? parseFloat(style.width) : element.clientWidth,
height: style.height ? parseFloat(style.height) : element.clientHeight,
}; // m41 = translateX, m42 = translateY
}
setPlacementFromConstraint(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) {
if (config.featureToggles.canvasPanelPanZoom) {
this.setPlacementFromConstraint2(elementContainer, parentContainer, transformScale);
return;
}
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
@@ -389,6 +532,101 @@ export class ElementState implements LayerElement {
this.getScene()?.save();
}
setPlacementFromConstraint2(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) {
const scene = this.getScene()!;
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
const elementRect = this.getTopLeftValues(this.div!);
if (!elementContainer) {
elementContainer = this.div && this.div.getBoundingClientRect();
}
// let parentBorderWidth = 0;
if (!parentContainer) {
parentContainer = this.div && this.div.parentElement?.getBoundingClientRect();
}
const relativeTop = Math.round(elementRect.top);
const relativeBottom = Math.round(scene.height - elementRect.top - elementRect.height);
const relativeLeft = Math.round(elementRect.left);
const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width);
const placement: Placement = {};
const width = elementRect.width;
const height = elementRect.height;
// INFO: calculate it anyway to be able to use it for pan&zoom
placement.top = relativeTop;
placement.left = relativeLeft;
switch (vertical) {
case VerticalConstraint.Top:
placement.top = relativeTop;
placement.height = height;
break;
case VerticalConstraint.Bottom:
placement.bottom = relativeBottom;
placement.height = height;
break;
case VerticalConstraint.TopBottom:
placement.top = relativeTop;
placement.bottom = relativeBottom;
break;
case VerticalConstraint.Center:
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
const parentCenter = scene.height / 2; // Use scene height instead of scaled viewport height
const distanceFromCenter = parentCenter - elementCenter;
placement.top = distanceFromCenter;
placement.height = height;
break;
case VerticalConstraint.Scale:
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = relativeLeft;
placement.width = width;
break;
case HorizontalConstraint.Right:
placement.right = relativeRight;
placement.width = width;
break;
case HorizontalConstraint.LeftRight:
placement.left = relativeLeft;
placement.right = relativeRight;
break;
case HorizontalConstraint.Center:
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenter = scene.width / 2; // Use scene width instead of scaled viewport width
const distanceFromCenter = parentCenter - elementCenter;
placement.left = distanceFromCenter;
placement.width = width;
break;
case HorizontalConstraint.Scale:
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
break;
}
if (this.options.placement?.rotation) {
placement.rotation = this.options.placement.rotation;
placement.width = this.options.placement.width;
placement.height = this.options.placement.height;
}
this.options.placement = placement;
this.applyLayoutStylesToDiv();
this.revId++;
this.getScene()?.save();
}
updateData(ctx: DimensionContext) {
if (this.item.prepareData) {
this.data = this.item.prepareData(ctx, this.options);
@@ -576,12 +814,12 @@ export class ElementState implements LayerElement {
// kinda like:
// https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44
applyResize = (event: OnResize, transformScale = 1) => {
applyResize = (event: OnResize) => {
const placement = this.options.placement!;
const style = event.target.style;
let deltaX = event.delta[0] / transformScale;
let deltaY = event.delta[1] / transformScale;
let deltaX = event.delta[0];
let deltaY = event.delta[1];
let dirLR = event.direction[0];
let dirTB = event.direction[1];
@@ -602,14 +840,22 @@ export class ElementState implements LayerElement {
} else if (dirLR === -1) {
placement.left! -= deltaX;
placement.width = event.width;
style.left = `${placement.left}px`;
if (config.featureToggles.canvasPanelPanZoom) {
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
} else {
style.left = `${placement.left}px`;
}
style.width = `${placement.width}px`;
}
if (dirTB === -1) {
placement.top! -= deltaY;
placement.height = event.height;
style.top = `${placement.top}px`;
if (config.featureToggles.canvasPanelPanZoom) {
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
} else {
style.top = `${placement.top}px`;
}
style.height = `${placement.height}px`;
} else if (dirTB === 1) {
placement.height = event.height;
@@ -831,6 +1077,7 @@ export class ElementState implements LayerElement {
onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined}
role="button"
tabIndex={0}
style={{ userSelect: 'none' }}
>
<item.display
key={`${this.UID}/${this.revId}`}
+146 -35
View File
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import InfiniteViewer from 'infinite-viewer';
import Moveable from 'moveable';
import { createRef, CSSProperties, RefObject } from 'react';
import { ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch';
import { CSSProperties } from 'react';
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import Selecto from 'selecto';
@@ -28,9 +28,9 @@ import {
import { CanvasContextMenu } from 'app/plugins/panel/canvas/components/CanvasContextMenu';
import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip';
import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections';
import { Connections2 } from 'app/plugins/panel/canvas/components/connections/Connections2';
import { Options } from 'app/plugins/panel/canvas/panelcfg.gen';
import { AnchorPoint, CanvasTooltipPayload } from 'app/plugins/panel/canvas/types';
import { getTransformInstance } from 'app/plugins/panel/canvas/utils';
import appEvents from '../../../core/app_events';
import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
@@ -38,11 +38,10 @@ import { getDashboardSrv } from '../../dashboard/services/DashboardSrv';
import { CanvasFrameOptions } from '../frame';
import { DEFAULT_CANVAS_ELEMENT_CONFIG } from '../registry';
import { SceneTransformWrapper } from './SceneTransformWrapper';
import { ElementState } from './element';
import { FrameState } from './frame';
import { RootElement } from './root';
import { initMoveable } from './sceneAbleManagement';
import { initMoveable, calculateZoomToFitScale } from './sceneAbleManagement';
import { findElementByTarget } from './sceneElementManagement';
export interface SelectionParams {
@@ -63,32 +62,30 @@ export class Scene {
width = 0;
height = 0;
scale = 1;
scrollLeft = 0;
scrollTop = 0;
style: CSSProperties = {};
data?: PanelData;
selecto?: Selecto;
moveable?: Moveable;
infiniteViewer?: InfiniteViewer;
div?: HTMLDivElement;
connections: Connections;
viewerDiv?: HTMLDivElement;
viewportDiv?: HTMLDivElement;
connections: Connections | Connections2;
currentLayer?: FrameState;
isEditingEnabled?: boolean;
shouldShowAdvancedTypes?: boolean;
shouldPanZoom?: boolean;
shouldInfinitePan?: boolean;
zoomToContent?: boolean;
tooltipMode?: TooltipDisplayMode;
skipNextSelectionBroadcast = false;
ignoreDataUpdate = false;
panel: CanvasPanel;
contextMenuVisible?: boolean;
openContextMenu?: (position: AnchorPoint) => void;
contextMenuOnVisibilityChange = (visible: boolean) => {
this.contextMenuVisible = visible;
const transformInstance = getTransformInstance(this);
if (transformInstance) {
if (visible) {
transformInstance.setup.disabled = true;
} else {
transformInstance.setup.disabled = false;
}
}
};
isPanelEditing = locationService.getSearchObject().editPanel !== undefined;
@@ -107,7 +104,6 @@ export class Scene {
subscription: Subscription;
targetsToSelect = new Set<HTMLDivElement>();
transformComponentRef: RefObject<ReactZoomPanPinchContentRef> | undefined;
constructor(
options: Options,
@@ -129,8 +125,7 @@ export class Scene {
});
this.panel = panel;
this.connections = new Connections(this);
this.transformComponentRef = createRef();
this.connections = config.featureToggles.canvasPanelPanZoom ? new Connections2(this) : new Connections(this);
}
getNextElementName = (isFrame = false) => {
@@ -153,7 +148,7 @@ export class Scene {
};
load(options: Options, enableEditing: boolean) {
const { root, showAdvancedTypes, panZoom, infinitePan, tooltip } = options;
const { root, showAdvancedTypes, panZoom, zoomToContent, tooltip } = options;
const tooltipMode = tooltip?.mode ?? TooltipDisplayMode.Single;
this.root = new RootElement(
@@ -168,18 +163,39 @@ export class Scene {
this.isEditingEnabled = enableEditing;
this.shouldShowAdvancedTypes = showAdvancedTypes;
this.shouldPanZoom = panZoom;
this.shouldInfinitePan = infinitePan;
this.zoomToContent = zoomToContent;
this.tooltipMode = tooltipMode;
setTimeout(() => {
if (this.div) {
// If editing is enabled, clear selecto instance
const destroySelecto = enableEditing;
initMoveable(destroySelecto, enableEditing, this);
this.currentLayer = this.root;
this.selection.next([]);
this.connections.select(undefined);
this.connections.updateState();
if (config.featureToggles.canvasPanelPanZoom) {
if (this.viewportDiv && this.viewerDiv) {
if (!this.shouldPanZoom) {
this.scale = 1;
this.scrollLeft = 0;
this.scrollTop = 0;
}
// If editing is enabled, clear selecto instance
const destroySelecto = enableEditing;
initMoveable(destroySelecto, enableEditing, this);
this.currentLayer = this.root;
this.selection.next([]);
this.connections.select(undefined);
this.connections.updateState();
// update initial connections svg size
this.updateConnectionsSize();
this.fitContent(this, zoomToContent);
}
} else {
if (this.div) {
// If editing is enabled, clear selecto instance
const destroySelecto = enableEditing;
initMoveable(destroySelecto, enableEditing, this);
this.currentLayer = this.root;
this.selection.next([]);
this.connections.select(undefined);
this.connections.updateState();
}
}
});
return this.root;
@@ -207,12 +223,53 @@ export class Scene {
if (this.selecto?.getSelectedTargets().length) {
this.clearCurrentSelection();
}
if (config.featureToggles.canvasPanelPanZoom) {
this.updateConnectionsSize();
this.fitContent(this, this.zoomToContent!);
// TODO: This is a workaround to apply styles to the elements after the size update.
// It's a good to go approach used by movable creator, but maybe we can find a better way.
this.root.elements.forEach((el) => {
el.applyLayoutStylesToDiv(false);
});
// TODO: This is a workaround to apply styles to the elements after the size update.
// Remove this after dealing with the connection anchors stacking context issue.
if (this.connections.connectionAnchorDiv) {
this.connections.connectionAnchorDiv.style.display = 'none';
}
}
}
updateConnectionsSize() {
const svgConnections = this.connections.connectionsSVG;
if (svgConnections) {
const scale = this.infiniteViewer!.getZoom();
// NOTE: sometimes getScrollLeft and getScrollTop return NaN,
// so we use || 0 to ensure we have a valid number
const left = this.infiniteViewer!.getScrollLeft() || 0;
const top = this.infiniteViewer!.getScrollTop() || 0;
const width = this.width;
const height = this.height;
svgConnections.style.left = `${left}px`;
svgConnections.style.top = `${top}px`;
svgConnections.style.width = `${width / scale}px`;
svgConnections.style.height = `${height / scale}px`;
svgConnections.setAttribute('viewBox', `${left} ${top} ${width / scale} ${height / scale}`);
}
}
clearCurrentSelection(skipNextSelectionBroadcast = false) {
this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
let event: MouseEvent = new MouseEvent('click');
this.selecto?.clickTarget(event, this.div);
if (config.featureToggles.canvasPanelPanZoom) {
this.selecto?.clickTarget(event, this.viewportDiv);
} else {
this.selecto?.clickTarget(event, this.div);
}
}
save = (updateMoveable = false) => {
@@ -220,8 +277,15 @@ export class Scene {
if (updateMoveable) {
setTimeout(() => {
if (this.div) {
initMoveable(true, this.isEditingEnabled, this);
if (config.featureToggles.canvasPanelPanZoom) {
if (this.viewportDiv && this.viewerDiv) {
initMoveable(true, this.isEditingEnabled, this);
this.updateConnectionsSize();
}
} else {
if (this.div) {
initMoveable(true, this.isEditingEnabled, this);
}
}
});
}
@@ -247,6 +311,14 @@ export class Scene {
this.div = sceneContainer;
};
setViewerRef = (viewerContainer: HTMLDivElement) => {
this.viewerDiv = viewerContainer;
};
setViewportRef = (viewportContainer: HTMLDivElement) => {
this.viewportDiv = viewportContainer;
};
select = (selection: SelectionParams) => {
if (this.selecto) {
this.selecto.setSelectedTargets(selection.targets);
@@ -285,6 +357,16 @@ export class Scene {
}
};
fitContent = (scene: Scene, zoomToContent: boolean) => {
const { root, viewerDiv, infiniteViewer } = scene;
if (zoomToContent && root.div && infiniteViewer && viewerDiv) {
const dimentions = calculateZoomToFitScale(Array.from(root.div.children), viewerDiv);
const { scale, centerX, centerY } = dimentions;
infiniteViewer.setZoom(scale);
infiniteViewer.scrollTo(centerX, centerY);
}
};
render() {
const hasDataLinks = this.tooltipPayload?.element?.getLinks && this.tooltipPayload.element.getLinks({}).length > 0;
const hasActions =
@@ -295,7 +377,7 @@ export class Scene {
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid && isTooltipEnabled;
const sceneDiv = (
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
<>
{this.connections.render()}
{this.root.render()}
{this.isEditingEnabled && (
@@ -312,13 +394,30 @@ export class Scene {
<CanvasTooltip scene={this} />
</Portal>
)}
</div>
</>
);
return config.featureToggles.canvasPanelPanZoom ? (
<SceneTransformWrapper scene={this}>{sceneDiv}</SceneTransformWrapper>
<div className={this.styles.viewer} ref={this.setViewerRef} key={this.revId} data-testid="canvas-scene-wrapper">
<div
className={this.styles.viewport}
ref={this.setViewportRef}
key={this.revId}
data-testid="canvas-scene-pan-zoom"
>
{sceneDiv}
</div>
</div>
) : (
sceneDiv
<div
key={this.revId}
className={this.styles.wrap}
style={this.style}
ref={this.setRef}
data-testid="canvas-scene"
>
{sceneDiv}
</div>
);
}
}
@@ -328,4 +427,16 @@ const getStyles = () => ({
overflow: 'hidden',
position: 'relative',
}),
selected: css({
zIndex: '999 !important',
}),
viewer: css({
overflow: 'hidden',
width: '100%',
height: '100%',
}),
viewport: css({
width: '100%',
height: '100%',
}),
});
@@ -1,13 +1,14 @@
import InfiniteViewer from 'infinite-viewer';
import Moveable from 'moveable';
import Selecto from 'selecto';
import { config } from 'app/core/config';
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors';
import {
CONNECTION_VERTEX_ID,
CONNECTION_VERTEX_ADD_ID,
} from 'app/plugins/panel/canvas/components/connections/Connections';
import { VerticalConstraint, HorizontalConstraint } from 'app/plugins/panel/canvas/panelcfg.gen';
import { getParent } from 'app/plugins/panel/canvas/utils';
import { dimensionViewable, constraintViewable, settingsViewable } from './ables';
import { ElementState } from './element';
@@ -15,6 +16,8 @@ import { FrameState } from './frame';
import { Scene } from './scene';
import { findElementByTarget } from './sceneElementManagement';
const ZOOM_RANGE = [0.1, 4]; // Minimum zoom 0.1x (10%), maximum zoom 4x (400%)
// Helper function that disables custom able functionality
const disableCustomables = (moveable: Moveable) => {
moveable!.props = {
@@ -95,8 +98,8 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
}
scene.selecto = new Selecto({
container: scene.div,
rootContainer: getParent(scene),
rootContainer: config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv : scene.div,
dragContainer: config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv : scene.div,
selectableTargets: targetElements,
toggleContinueSelect: 'shift',
selectFromInside: false,
@@ -106,7 +109,7 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true };
const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true };
scene.moveable = new Moveable(scene.div!, {
scene.moveable = new Moveable(config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv! : scene.div!, {
draggable: allowChanges && !scene.editModeEnabled.getValue(),
resizable: allowChanges,
@@ -137,6 +140,12 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
if (targetedElement) {
targetedElement.applyRotate(event);
if (config.featureToggles.canvasPanelPanZoom) {
if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) {
scene.moveableActionCallback(true);
}
}
}
})
.on('rotateGroup', (e) => {
@@ -221,9 +230,7 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
e.events.forEach((event) => {
const targetedElement = findElementByTarget(event.target, scene.root.elements);
if (targetedElement) {
if (targetedElement) {
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
}
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
// re-add the selected elements to the snappable guidelines
if (scene.moveable && scene.moveable.elementGuidelines) {
@@ -280,11 +287,23 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
}
}
}
// Temporarily set Top-Left constraints on each group element for predictable resizing; restore originals on end.
for (let event of e.events) {
const targetedElement = findElementByTarget(event.target, scene.root.elements);
if (targetedElement) {
targetedElement.tempConstraint = { ...targetedElement.options.constraint };
targetedElement.options.constraint = {
vertical: VerticalConstraint.Top,
horizontal: HorizontalConstraint.Left,
};
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
}
}
})
.on('resize', (event) => {
const targetedElement = findElementByTarget(event.target, scene.root.elements);
if (targetedElement) {
targetedElement.applyResize(event, scene.scale);
targetedElement.applyResize(event);
if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) {
scene.moveableActionCallback(true);
@@ -319,7 +338,6 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
targetedElement.options.constraint = targetedElement.tempConstraint;
targetedElement.tempConstraint = undefined;
}
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
// re-add the selected element to the snappable guidelines
@@ -409,4 +427,194 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
.on('dragEnd', (event) => {
clearTimeout(event.data.timer);
});
if (config.featureToggles.canvasPanelPanZoom) {
/******************/
/* infiniteViewer */
/******************/
scene.infiniteViewer = new InfiniteViewer(scene.viewerDiv!, scene.viewportDiv!, {
preventWheelClick: false,
useAutoZoom: true,
useMouseDrag: false, // `true` blocks metricValue dropdown
useWheelScroll: scene.shouldPanZoom,
displayHorizontalScroll: false,
displayVerticalScroll: false,
zoomRange: ZOOM_RANGE,
});
scene.infiniteViewer.setZoom(scene.scale);
scene.infiniteViewer.scrollTo(scene.scrollLeft, scene.scrollTop);
// Handles context menu activation
// Uses openContextMenu with coordinates when available (after CanvasContextMenu mounts), but
// uses the basic visibility toggle when openContextMenu isn't ready (as a fallback)
const triggerContextMenu = (x: number, y: number) => {
if (scene.openContextMenu) {
scene.openContextMenu({ x, y });
} else {
scene.contextMenuOnVisibilityChange(true);
}
};
/* ----------------------------- EVENT HANDLERS ----------------------------- */
// Helper for panning with mouse drag (middle mouse or Ctrl+right-click)
// TODO: It was implemented as a workaround to unblock left click metricsValue dropdown,
// but it should be replaced with a more robust solution that doesn't interfere with left click interactions.
function startPanning(e: MouseEvent) {
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const startScrollLeft = scene.infiniteViewer!.getScrollLeft();
const startScrollTop = scene.infiniteViewer!.getScrollTop();
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = startX - moveEvent.clientX;
const deltaY = startY - moveEvent.clientY;
const scaleAdjustedDeltaX = deltaX / scene.scale;
const scaleAdjustedDeltaY = deltaY / scene.scale;
scene.infiniteViewer!.scrollTo(startScrollLeft + scaleAdjustedDeltaX, startScrollTop + scaleAdjustedDeltaY);
moveEvent.preventDefault();
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
// Right click
scene.viewerDiv!.addEventListener('contextmenu', (e) => {
if (e.ctrlKey && e.button === 2 && scene.shouldPanZoom) {
// Enable panning with Ctrl+right-click
startPanning(e);
} else {
// Prevent default browser context menu
e.preventDefault();
triggerContextMenu(e.pageX, e.pageY);
}
});
// Enable panning with middle mouse button (wheel button)
scene.viewerDiv!.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 1 && scene.shouldPanZoom) {
// Middle mouse button
startPanning(e);
}
});
// Prevent wheel scrolling when pan/zoom is disabled
scene.viewportDiv!.addEventListener(
'wheel',
(e) => {
if (!scene.shouldPanZoom) {
e.stopImmediatePropagation();
e.preventDefault();
}
},
{ passive: false }
);
// Reset zoom and scroll position on double click
scene.viewerDiv!.addEventListener('dblclick', (e: MouseEvent) => {
// Only reset if not in edit mode and pan/zoom is enabled
if (!scene.editModeEnabled.getValue() && scene.shouldPanZoom && scene.infiniteViewer) {
scene.infiniteViewer.setZoom(1);
scene.infiniteViewer.scrollTo(0, 0);
}
});
// Mouse scroll click
// Only allow panning with middle mouse button (button 1)
// Left click is reserved for selection/manipulation, right click for context menu
scene.infiniteViewer!.on('dragStart', (e) => {
if (e.inputEvent.button !== 1) {
e.preventDefault();
e.preventDrag();
}
});
// Scroll
scene.infiniteViewer!.on('scroll', () => {
// TODO: clear current selection is default behaviour on zoom-in or zoom-out,
// but looks like we prevented this event to trigger at some point
scene.clearCurrentSelection(true);
scene.updateConnectionsSize();
scene.scale = scene.infiniteViewer!.getZoom();
scene.scrollLeft = scene.infiniteViewer!.getScrollLeft();
scene.scrollTop = scene.infiniteViewer!.getScrollTop();
});
}
};
// Zoom to content helper functions
export function calculateZoomToFitScale(elements: Element[], container: HTMLDivElement, paddingRatio = 0.05) {
const bounds = calculateGroupBoundingBox(elements);
const containerRect = container.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
const paddedWidth = containerWidth * (1 - 2 * paddingRatio);
const paddedHeight = containerHeight * (1 - 2 * paddingRatio);
const scaleX = paddedWidth / bounds.width;
const scaleY = paddedHeight / bounds.height;
// Use the smaller one to fit both horizontally and vertically
const scale = Math.min(scaleX, scaleY);
// calculate value to move to center
const centerX = (bounds.centerX * scale - containerWidth / 2) / scale;
const centerY = (bounds.centerY * scale - containerHeight / 2) / scale;
return {
scale,
centerX,
centerY,
};
}
export function extractTranslateFromTransform(transform: string) {
const matrix = new DOMMatrix(transform);
return { x: matrix.m41, y: matrix.m42 }; // m41 = translateX, m42 = translateY
}
export function calculateGroupBoundingBox(elements: Element[]) {
let minX = Infinity,
minY = Infinity;
let maxX = -Infinity,
maxY = -Infinity;
for (const el of elements) {
const style = window.getComputedStyle(el);
const { x: tx, y: ty } = extractTranslateFromTransform(style.transform || '');
const width = parseFloat(style.width);
const height = parseFloat(style.height);
const left = tx;
const top = ty;
const right = tx + width;
const bottom = ty + height;
minX = Math.min(minX, left);
minY = Math.min(minY, top);
maxX = Math.max(maxX, right);
maxY = Math.max(maxY, bottom);
}
return {
left: minX,
top: minY,
right: maxX,
bottom: maxY,
width: maxX - minX,
height: maxY - minY,
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
};
}
+26 -15
View File
@@ -49,6 +49,7 @@ export class CanvasPanel extends Component<Props, State> {
readonly scene: Scene;
private subs = new Subscription();
private queryEditorLoaded = false;
needsReload = false;
isEditing = locationService.getSearchObject().editPanel !== undefined;
@@ -97,10 +98,7 @@ export class CanvasPanel extends Component<Props, State> {
this.panelContext = this.context;
if (this.panelContext.onInstanceStateChange) {
this.panelContext.onInstanceStateChange({
scene: this.scene,
layer: this.scene.root,
});
this.panelContext.onInstanceStateChange({ scene: this.scene, layer: this.scene.root });
this.subs.add(
this.scene.selection.subscribe({
@@ -117,11 +115,7 @@ export class CanvasPanel extends Component<Props, State> {
}
});
this.panelContext?.onInstanceStateChange!({
scene: this.scene,
selected: v,
layer: this.scene.root,
});
this.panelContext?.onInstanceStateChange!({ scene: this.scene, selected: v, layer: this.scene.root });
},
})
);
@@ -160,9 +154,29 @@ export class CanvasPanel extends Component<Props, State> {
);
}
// Reset the size update flag when entering edit mode
if (this.isEditing) {
this.queryEditorLoaded = false;
}
canvasInstances.push(this);
}
componentDidUpdate(prevProps: Props) {
// Check if we're in edit mode and dimensions have changed (indicating query editor loaded)
if (this.isEditing && !this.queryEditorLoaded) {
const dimensionsChanged = prevProps.width !== this.props.width || prevProps.height !== this.props.height;
if (dimensionsChanged) {
this.queryEditorLoaded = true;
// Small delay to ensure layout is completely settled
requestAnimationFrame(() => {
this.scene.updateSize(this.props.width, this.props.height);
});
}
}
}
componentWillUnmount() {
this.scene.subscription.unsubscribe();
this.subs.unsubscribe();
@@ -175,10 +189,7 @@ export class CanvasPanel extends Component<Props, State> {
// even the editor gets current state from the same scene instance!
onUpdateScene = (root: CanvasFrameOptions) => {
const { onOptionsChange, options } = this.props;
onOptionsChange({
...options,
root,
});
onOptionsChange({ ...options, root });
this.setState({ refresh: this.state.refresh + 1 });
activePanelSubject.next({ panel: this });
@@ -224,14 +235,14 @@ export class CanvasPanel extends Component<Props, State> {
const shouldShowAdvancedTypesSwitched =
this.props.options.showAdvancedTypes !== nextProps.options.showAdvancedTypes;
const panZoomSwitched = this.props.options.panZoom !== nextProps.options.panZoom;
const infinitePanSwitched = this.props.options.infinitePan !== nextProps.options.infinitePan;
const zoomToContentSwitched = this.props.options.zoomToContent !== nextProps.options.zoomToContent;
const tooltipModeSwitched = this.props.options.tooltip?.mode !== nextProps.options.tooltip?.mode;
if (
this.needsReload ||
inlineEditingSwitched ||
shouldShowAdvancedTypesSwitched ||
panZoomSwitched ||
infinitePanSwitched ||
zoomToContentSwitched ||
tooltipModeSwitched
) {
if (inlineEditingSwitched) {
@@ -6,6 +6,7 @@ import { first } from 'rxjs/operators';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ContextMenu, MenuItem, MenuItemProps } from '@grafana/ui';
import { config } from 'app/core/config';
import { ElementState } from 'app/features/canvas/runtime/element';
import { FrameState } from 'app/features/canvas/runtime/frame';
import { Scene } from 'app/features/canvas/runtime/scene';
@@ -31,6 +32,20 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) =
const selectedElements = scene.selecto?.getSelectedTargets();
const rootLayer: FrameState | undefined = panel.context?.instanceState?.layer;
useEffect(() => {
if (config.featureToggles.canvasPanelPanZoom) {
scene.openContextMenu = (position: AnchorPoint) => {
setAnchorPoint(position);
setIsMenuVisible(true);
onVisibilityChange(true);
};
// Clean up the openContextMenu on unmount
return () => (scene.openContextMenu = undefined);
}
return undefined;
}, [scene, onVisibilityChange]);
const handleContextMenu = useCallback(
(event: Event) => {
if (!(event instanceof MouseEvent) || event.ctrlKey) {
@@ -40,7 +55,9 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) =
event.preventDefault();
panel.setActivePanel();
const shouldSelectElement = event.currentTarget !== scene.div;
const shouldSelectElement = config.featureToggles.canvasPanelPanZoom
? event.currentTarget !== scene.viewportDiv
: event.currentTarget !== scene.div;
if (
shouldSelectElement &&
(event.currentTarget instanceof HTMLElement || event.currentTarget instanceof SVGElement)
@@ -133,6 +150,10 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) =
const sceneContainerDimensions = scene.div.getBoundingClientRect();
offsetY = (offsetY - sceneContainerDimensions.top) / transformScale;
offsetX = (offsetX - sceneContainerDimensions.left) / transformScale;
} else if (scene.viewportDiv) {
const sceneContainerDimensions = scene.viewportDiv.getBoundingClientRect();
offsetY -= sceneContainerDimensions.top;
offsetX -= sceneContainerDimensions.left;
}
onAddItem(option, rootLayer, {
@@ -0,0 +1,171 @@
import { css } from '@emotion/css';
import { useRef } from 'react';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { ConnectionCoordinates } from 'app/features/canvas/element';
type Props = {
setRef: (anchorElement: HTMLDivElement) => void;
setAnchorsRef: (anchorsElement: HTMLDivElement) => void;
handleMouseLeave: (
event: React.MouseEvent<Element, MouseEvent> | React.FocusEvent<HTMLDivElement, Element>
) => boolean;
};
export const CONNECTION_ANCHOR_DIV_ID = 'connectionControl';
export const CONNECTION_ANCHOR_ALT = 'connection anchor';
export const CONNECTION_ANCHOR_HIGHLIGHT_OFFSET = 8;
// Unit is percentage from the middle of the element
// 0, 0 middle; -1, -1 bottom left; 1, 1 top right
export 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 },
];
export const ANCHOR_PADDING = 3;
export const HALF_SIZE = 2.5;
const zIndex = {
ROOT: 1000,
ANCHOR: 1001,
HIGHLIGHT: 1002,
};
enum PointerEvents {
ROOT = 'none',
MOUSEOUT_DIV = 'none',
ANCHOR = 'auto',
HIGHLIGHT = 'auto',
}
export const ConnectionAnchors = ({ setRef, setAnchorsRef, handleMouseLeave }: Props) => {
const highlightEllipseRef = useRef<HTMLDivElement>(null);
const styles = useStyles2(getStyles);
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 + ${ANCHOR_PADDING}px)`;
highlightEllipseRef.current.style.left = `calc(${event.target.style.left} - ${halfSizeHighlightEllipse}px + ${ANCHOR_PADDING}px)`;
}
};
const onMouseLeaveHighlightElement = () => {
if (highlightEllipseRef.current) {
highlightEllipseRef.current.style.display = 'none';
}
};
const handleMouseLeaveAnchors = (
event: React.MouseEvent<Element, MouseEvent> | React.FocusEvent<HTMLDivElement, Element>
) => {
const didHideAnchors = handleMouseLeave(event);
if (didHideAnchors) {
onMouseLeaveHighlightElement();
}
};
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}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`,
left: `calc(${anchor.x * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`,
};
return (
<img
id={id}
ref={(element) => {
if (element) {
// After React 15+, inline styles no longer support !important
element.style.setProperty('pointer-events', PointerEvents.ANCHOR, 'important');
}
}}
key={id}
alt={CONNECTION_ANCHOR_ALT}
className={styles.anchor}
style={style}
src={anchorImage}
onMouseEnter={onMouseEnterAnchor}
/>
);
});
};
return (
<div className={styles.root} ref={setRef}>
<div className={styles.mouseoutDiv} onMouseOut={handleMouseLeaveAnchors} onBlur={handleMouseLeaveAnchors} />
<div
id={CONNECTION_ANCHOR_DIV_ID}
ref={highlightEllipseRef}
className={styles.highlightElement}
onMouseLeave={onMouseLeaveHighlightElement}
/>
<div ref={setAnchorsRef}>{generateAnchors()}</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
root: css({
position: 'absolute',
display: 'none',
zIndex: `${zIndex.ROOT} !important`,
pointerEvents: PointerEvents.ROOT,
}),
mouseoutDiv: css({
position: 'absolute',
margin: '-30px',
width: 'calc(100% + 60px)',
height: 'calc(100% + 60px)',
pointerEvents: PointerEvents.MOUSEOUT_DIV,
}),
anchor: css({
padding: `${ANCHOR_PADDING}px`,
position: 'absolute',
cursor: 'cursor',
width: `calc(5px + 2 * ${ANCHOR_PADDING}px)`,
height: `calc(5px + 2 * ${ANCHOR_PADDING}px)`,
zIndex: `${zIndex.ANCHOR} !important`,
pointerEvents: PointerEvents.ANCHOR,
userSelect: 'none',
}),
highlightElement: css({
backgroundColor: '#00ff00',
opacity: 0.3,
position: 'absolute',
cursor: 'cursor',
pointerEvents: PointerEvents.HIGHLIGHT,
width: '16px',
height: '16px',
borderRadius: theme.shape.radius.circle,
display: 'none',
zIndex: `${zIndex.HIGHLIGHT} !important`,
}),
});
@@ -0,0 +1,596 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { ConnectionDirection } from 'app/features/canvas/element';
import { Scene } from 'app/features/canvas/runtime/scene';
import { ConnectionCoordinates } from '../../panelcfg.gen';
import { ConnectionState } from '../../types';
import {
calculateAngle,
calculateCoordinates2,
calculateDistance,
calculateMidpoint,
getConnectionStyles,
} from '../../utils';
import { CONNECTION_VERTEX_ADD_ID, CONNECTION_VERTEX_ID } from './Connections';
type Props = {
setLineRef: (anchorElement: SVGLineElement) => void;
setVertexPathRef: (anchorElement: SVGPathElement) => void;
setVertexRef: (anchorElement: SVGCircleElement) => void;
setConnectionsSVGRef: (anchorElement: SVGSVGElement) => void;
scene: Scene;
};
let idCounter = 0;
const htmlElementTypes = ['input', 'textarea'];
export const ConnectionSVG = ({ setLineRef, setVertexPathRef, setVertexRef, setConnectionsSVGRef, scene }: Props) => {
const styles = useStyles2(getStyles);
const headId = Date.now() + '_' + idCounter++;
const CONNECTION_LINE_ID = useMemo(() => `connectionLineId-${headId}`, [headId]);
const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]);
const defaultArrowColor = config.theme2.colors.text.primary;
const defaultArrowSize = 2;
const defaultArrowDirection = ConnectionDirection.Forward;
const maximumVertices = 10;
const [selectedConnection, setSelectedConnection] = useState<ConnectionState | undefined>(undefined);
// Need to use ref to ensure state is not stale in event handler
const selectedConnectionRef = useRef(selectedConnection);
useEffect(() => {
selectedConnectionRef.current = selectedConnection;
});
useEffect(() => {
if (scene.panel.context.instanceState?.selectedConnection) {
setSelectedConnection(scene.panel.context.instanceState?.selectedConnection);
}
}, [scene.panel.context.instanceState?.selectedConnection]);
const onKeyUp = (e: KeyboardEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (htmlElementTypes.indexOf(target.nodeName.toLowerCase()) > -1) {
return;
}
// Backspace (8) or delete (46)
if (e.keyCode === 8 || e.keyCode === 46) {
if (selectedConnectionRef.current && selectedConnectionRef.current.source) {
selectedConnectionRef.current.source.options.connections =
selectedConnectionRef.current.source.options.connections?.filter(
(connection) => connection !== selectedConnectionRef.current?.info
);
selectedConnectionRef.current.source.onChange(selectedConnectionRef.current.source.options);
setSelectedConnection(undefined);
scene.connections.select(undefined);
scene.connections.updateState();
scene.save();
}
} 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);
scene.connections.select(undefined);
}
};
const selectConnection = (connection: ConnectionState) => {
if (scene.isEditingEnabled) {
setSelectedConnection(connection);
scene.connections.select(connection);
document.addEventListener('keyup', onKeyUp);
scene.selecto!.rootContainer!.addEventListener('click', clearSelectedConnection);
}
};
// Figure out target and then target's relative coordinates drawing (if no target do parent)
const renderConnections = () => {
return (
scene.connections.state
// Render selected connection last, ensuring it is above other connections
.sort((_a, b) => (selectedConnection === b && scene.panel.context.instanceState.selectedConnection ? -1 : 0))
.map((v, idx) => {
const { source, target, info, vertices, index } = v;
const sourceRect = source.div;
const parent = source.div?.parentElement;
const parentRect = scene.viewportDiv;
if (!sourceRect || !parent || !parentRect) {
return;
}
let { x1, y1, x2, y2 } = calculateCoordinates2(source, target, info);
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
if (v.sourceOriginal && v.targetOriginal) {
xStart = v.sourceOriginal.x;
yStart = v.sourceOriginal.y;
xEnd = v.targetOriginal.x;
yEnd = v.targetOriginal.y;
} else if (source.options.connections) {
// If original source or target coordinates are not set for the current connection, set them
if (
!source.options.connections[index].sourceOriginal ||
!source.options.connections[index].targetOriginal
) {
source.options.connections[index].sourceOriginal = { x: x1, y: y1 };
source.options.connections[index].targetOriginal = { x: x2, y: y2 };
}
}
const midpoint = calculateMidpoint(x1, y1, x2, y2);
const xDist = xEnd - xStart;
const yDist = yEnd - yStart;
const { strokeColor, strokeWidth, strokeRadius, arrowDirection, lineStyle, shouldAnimate } =
getConnectionStyles(info, scene, defaultArrowSize, defaultArrowDirection);
const isSelected = selectedConnection === v && scene.panel.context.instanceState.selectedConnection;
const connectionCursorStyle = scene.isEditingEnabled ? 'grab' : '';
const selectedStyles = { stroke: '#44aaff', strokeOpacity: 0.6, strokeWidth: strokeWidth + 5 };
const CONNECTION_HEAD_ID_START = `connectionHeadStart-${headId + Math.random()}`;
const CONNECTION_HEAD_ID_END = `connectionHeadEnd-${headId + Math.random()}`;
const radius = strokeRadius;
// Create vertex path and populate array of add vertex controls
const addVertices: ConnectionCoordinates[] = [];
let pathString = `M${x1} ${y1} `;
if (vertices?.length) {
vertices.map((vertex, index) => {
const { x, y } = vertex;
// Convert vertex relative coordinates to scene coordinates
const X = x * xDist + xStart;
const Y = y * yDist + yStart;
// Initialize coordinates for first arc control point
let xa = X;
let ya = Y;
// Initialize coordinates for second arc control point
let xb = X;
let yb = Y;
// Initialize half arc distance and segment angles
let lHalfArc = 0;
let angle1 = 0;
let angle2 = 0;
// Only calculate arcs if there is a radius
if (radius) {
if (index < vertices.length - 1) {
const Xn = vertices[index + 1].x * xDist + xStart;
const Yn = vertices[index + 1].y * yDist + yStart;
if (index === 0) {
// First vertex
angle1 = calculateAngle(x1, y1, X, Y);
angle2 = calculateAngle(X, Y, Xn, Yn);
} else {
// All vertices
const previousVertex = vertices[index - 1];
const Xp = previousVertex.x * xDist + xStart;
const Yp = previousVertex.y * yDist + yStart;
angle1 = calculateAngle(Xp, Yp, X, Y);
angle2 = calculateAngle(X, Y, Xn, Yn);
}
} else {
// Last vertex
if (index > 0) {
// Not also the first vertex
const previousVertex = vertices[index - 1];
const Xp = previousVertex.x * xDist + xStart;
const Yp = previousVertex.y * yDist + yStart;
angle1 = calculateAngle(Xp, Yp, X, Y);
} else {
angle1 = calculateAngle(x1, y1, X, Y);
}
angle2 = calculateAngle(X, Y, x2, y2);
}
// Calculate angle between two segments where arc will be placed
const theta = angle2 - angle1; //radians
// Attempt to determine if arc is counter clockwise (ccw)
const ccw = theta < 0;
// Half arc is used for arc control points
lHalfArc = radius * Math.tan(theta / 2);
if (ccw) {
lHalfArc *= -1;
}
}
if (index === 0) {
// For first vertex
addVertices.push(
calculateMidpoint((x1 - xStart) / (xEnd - xStart), (y1 - yStart) / (yEnd - yStart), x, y)
);
// Only calculate arcs if there is a radius
if (radius) {
// Length of segment
const lSegment = calculateDistance(X, Y, x1, y1);
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) {
// Limit curve control points to mid segment
lHalfArc = 0.5 * lSegment;
}
// Default next point to last point
let Xn = x2;
let Yn = y2;
if (index < vertices.length - 1) {
// Not also the last point
const nextVertex = vertices[index + 1];
Xn = nextVertex.x * xDist + xStart;
Yn = nextVertex.y * yDist + yStart;
}
// Length of next segment
const lSegmentNext = calculateDistance(X, Y, Xn, Yn);
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegmentNext)) {
// Limit curve control points to mid segment
lHalfArc = 0.5 * lSegmentNext;
}
// Calculate arc control points
const lDelta = lSegment - lHalfArc;
xa = Math.round(lDelta * Math.cos(angle1) + x1);
ya = Math.round(lDelta * Math.sin(angle1) + y1);
xb = Math.round(lHalfArc * Math.cos(angle2) + X);
yb = Math.round(lHalfArc * Math.sin(angle2) + Y);
// Check if arc control points are inside of segment, otherwise swap sign
if ((xa > X && xa > x1) || (xa < X && xa < x1)) {
xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + x1;
ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + y1;
xb = -lHalfArc * Math.cos(angle2) + X;
yb = -lHalfArc * Math.sin(angle2) + Y;
}
}
} else {
// For all other vertices
const previousVertex = vertices[index - 1];
addVertices.push(calculateMidpoint(previousVertex.x, previousVertex.y, x, y));
// Only calculate arcs if there is a radius
if (radius) {
// Convert previous vertex relative coorindates to scene coordinates
const Xp = previousVertex.x * xDist + xStart;
const Yp = previousVertex.y * yDist + yStart;
// Length of segment
const lSegment = calculateDistance(X, Y, Xp, Yp);
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) {
// Limit curve control points to mid segment
lHalfArc = 0.5 * lSegment;
}
// Default next point to last point
let Xn = x2;
let Yn = y2;
if (index < vertices.length - 1) {
// Not also the last point
const nextVertex = vertices[index + 1];
Xn = nextVertex.x * xDist + xStart;
Yn = nextVertex.y * yDist + yStart;
}
// Length of next segment
const lSegmentNext = calculateDistance(X, Y, Xn, Yn);
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegmentNext)) {
// Limit curve control points to mid segment
lHalfArc = 0.5 * lSegmentNext;
}
// Calculate arc control points
const lDelta = lSegment - lHalfArc;
xa = Math.round(lDelta * Math.cos(angle1) + Xp);
ya = Math.round(lDelta * Math.sin(angle1) + Yp);
xb = Math.round(lHalfArc * Math.cos(angle2) + X);
yb = Math.round(lHalfArc * Math.sin(angle2) + Y);
// Check if arc control points are inside of segment, otherwise swap sign
if ((xa > X && xa > Xp) || (xa < X && xa < Xp)) {
xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + Xp;
ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + Yp;
xb = -lHalfArc * Math.cos(angle2) + X;
yb = -lHalfArc * Math.sin(angle2) + Y;
}
}
}
if (index === vertices.length - 1) {
// For last vertex only
addVertices.push(
calculateMidpoint((x2 - xStart) / (xEnd - xStart), (y2 - yStart) / (yEnd - yStart), x, y)
);
}
// Add segment to path
pathString += `L${xa} ${ya} `;
if (lHalfArc !== 0) {
// Add arc if applicable
pathString += `Q ${X} ${Y} ${xb} ${yb} `;
}
});
// Add last segment
pathString += `L${x2} ${y2}`;
}
const markerStart =
arrowDirection === ConnectionDirection.Reverse || arrowDirection === ConnectionDirection.Both
? `url(#${CONNECTION_HEAD_ID_START})`
: undefined;
const markerEnd =
arrowDirection === ConnectionDirection.Forward || arrowDirection === ConnectionDirection.Both
? `url(#${CONNECTION_HEAD_ID_END})`
: undefined;
const getAnimationDirection = () => {
let values = '100;0';
if (arrowDirection === ConnectionDirection.Reverse) {
values = '0;100';
}
return values;
};
return (
<g key={idx} onClick={() => selectConnection(v)}>
<defs>
<marker
id={CONNECTION_HEAD_ID_START}
markerWidth="10"
markerHeight="7"
refX="0"
refY="3.5"
orient="auto"
stroke={strokeColor}
>
<polygon points="10 0, 0 3.5, 10 7" fill={strokeColor} />
</marker>
<marker
id={CONNECTION_HEAD_ID_END}
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
stroke={strokeColor}
>
<polygon points="0 0, 10 3.5, 0 7" fill={strokeColor} />
</marker>
</defs>
{vertices?.length ? (
// Render path with vertices
<g>
{/* heighlight line */}
<path
id={`${CONNECTION_LINE_ID}_transparent`}
d={pathString}
cursor={connectionCursorStyle}
pointerEvents="auto"
stroke="transparent"
strokeWidth={15}
fill={'none'}
style={isSelected ? selectedStyles : {}}
/>
{/* real line */}
<path
d={pathString}
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeDasharray={lineStyle}
strokeDashoffset={1}
fill={'none'}
markerEnd={markerEnd}
markerStart={markerStart}
>
{shouldAnimate && (
<animate
attributeName="stroke-dashoffset"
values={getAnimationDirection()}
dur="5s"
calcMode="linear"
repeatCount="indefinite"
fill={'freeze'}
/>
)}
</path>
{isSelected && (
<g>
{/* vertices */}
{vertices.map((value, index) => {
return (
<circle
id={CONNECTION_VERTEX_ID}
data-index={index}
key={`${CONNECTION_VERTEX_ID}${index}_${idx}`}
cx={value.x * xDist + xStart}
cy={value.y * yDist + yStart}
r={5}
stroke={strokeColor}
className={styles.vertex}
cursor={'crosshair'}
pointerEvents="auto"
/>
);
})}
{/* midpoints */}
{vertices.length < maximumVertices &&
addVertices.map((value, index) => {
return (
<circle
id={CONNECTION_VERTEX_ADD_ID}
data-index={index}
key={`${CONNECTION_VERTEX_ADD_ID}${index}_${idx}`}
cx={value.x * xDist + xStart}
cy={value.y * yDist + yStart}
r={4}
stroke={strokeColor}
className={styles.addVertex}
cursor={'crosshair'}
pointerEvents="auto"
/>
);
})}
</g>
)}
</g>
) : (
// Render line without vertices
<g>
{/* heighlight line */}
<line
id={`${CONNECTION_LINE_ID}_transparent`}
cursor={connectionCursorStyle}
pointerEvents="auto"
stroke="transparent"
strokeWidth={15}
style={isSelected ? selectedStyles : {}}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
/>
{/* real line */}
<line
id={CONNECTION_LINE_ID}
stroke={strokeColor}
pointerEvents="auto"
strokeWidth={strokeWidth}
markerEnd={markerEnd}
markerStart={markerStart}
strokeDasharray={lineStyle}
strokeDashoffset={1}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
cursor={connectionCursorStyle}
>
{shouldAnimate && (
<animate
attributeName="stroke-dashoffset"
values={getAnimationDirection()}
dur="5s"
calcMode="linear"
repeatCount="indefinite"
fill={'freeze'}
/>
)}
</line>
{/* initial midpoint */}
{isSelected && (
<circle
id={CONNECTION_VERTEX_ADD_ID}
data-index={0}
cx={midpoint.x}
cy={midpoint.y}
r={4}
stroke={strokeColor}
className={styles.addVertex}
cursor={'crosshair'}
pointerEvents="auto"
/>
)}
</g>
)}
</g>
);
})
);
};
return (
<>
<svg ref={setConnectionsSVGRef} className={styles.connection}>
<defs>
<marker
id={EDITOR_HEAD_ID}
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
stroke={defaultArrowColor}
>
<polygon points="0 0, 10 3.5, 0 7" fill={defaultArrowColor} />
</marker>
</defs>
{/* svg line for connection creation */}
<line
ref={setLineRef}
stroke={defaultArrowColor}
strokeWidth={2}
markerEnd={`url(#${EDITOR_HEAD_ID})`}
style={{ display: 'none' }}
/>
{/* svg circle for initial vertex?
path? is it for the line drag handling? */}
<path
ref={setVertexPathRef}
stroke={defaultArrowColor}
strokeWidth={2}
strokeDasharray={'5, 5'}
fill={'none'}
style={{ display: 'none' }}
/>
<circle
ref={setVertexRef}
stroke={defaultArrowColor}
r={4}
className={styles.vertex}
style={{ display: 'none' }}
/>
{renderConnections()}
</svg>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
connection: css({
position: 'absolute',
zIndex: 1000,
pointerEvents: 'none',
}),
vertex: css({
fill: '#44aaff',
strokeWidth: 2,
}),
addVertex: css({
fill: '#44aaff',
opacity: 0.5,
strokeWidth: 1,
}),
});
@@ -43,6 +43,8 @@ export class Connections {
connectionVertex?: SVGCircleElement;
connectionSource?: ElementState;
connectionTarget?: ElementState;
// for back compatibility with Connections2
connectionsSVG?: SVGElement;
isDrawingConnection?: boolean;
selectedVertexIndex?: number;
didConnectionLeaveHighlight?: boolean;
@@ -0,0 +1,669 @@
import * as React from 'react';
import { BehaviorSubject } from 'rxjs';
import { config } from '@grafana/runtime';
import { CanvasConnection, ConnectionCoordinates, ConnectionPath } from 'app/features/canvas/element';
import { ElementState } from 'app/features/canvas/runtime/element';
import { Scene } from 'app/features/canvas/runtime/scene';
import { findElementByTarget } from 'app/features/canvas/runtime/sceneElementManagement';
import { ConnectionState } from '../../types';
import {
calculateAngle,
calculateCoordinates2,
getConnections,
getElementTransformAndDimensions,
getNormalizedRotatedOffset,
getParentBoundingClientRect,
isConnectionSource,
isConnectionTarget,
} from '../../utils';
import {
CONNECTION_ANCHOR_ALT,
CONNECTION_ANCHOR_HIGHLIGHT_OFFSET,
ANCHORS,
ANCHOR_PADDING,
HALF_SIZE,
} from './ConnectionAnchors';
import { ConnectionAnchors } from './ConnectionAnchors2';
import { ConnectionSVG } from './ConnectionSVG2';
export const CONNECTION_VERTEX_ID = 'vertex';
export const CONNECTION_VERTEX_ADD_ID = 'vertexAdd';
const CONNECTION_VERTEX_ORTHO_TOLERANCE = 0.05; // Cartesian ratio against vertical or horizontal tolerance
const CONNECTION_VERTEX_SNAP_TOLERANCE = (5 / 180) * Math.PI; // Multi-segment snapping angle in radians to trigger vertex removal
export class Connections2 {
scene: Scene;
connectionAnchorDiv?: HTMLDivElement;
anchorsDiv?: HTMLDivElement;
connectionLine?: SVGLineElement;
connectionVertexPath?: SVGPathElement;
connectionVertex?: SVGCircleElement;
connectionsSVG?: SVGElement;
connectionSource?: ElementState;
connectionTarget?: ElementState;
isDrawingConnection?: boolean;
selectedVertexIndex?: number;
didConnectionLeaveHighlight?: boolean;
state: ConnectionState[] = [];
readonly selection = new BehaviorSubject<ConnectionState | undefined>(undefined);
constructor(scene: Scene) {
this.scene = scene;
this.updateState();
}
select = (connection: ConnectionState | undefined) => {
if (connection === this.selection.value) {
return;
}
this.selection.next(connection);
};
updateState = () => {
this.state = getConnections(this.scene.byName);
const s = this.selection.value;
if (s) {
for (let c of this.state) {
if (c.source === s.source && c.index === s.index) {
this.selection.next(c);
break;
}
}
}
};
setConnectionAnchorRef = (anchorElement: HTMLDivElement) => {
this.connectionAnchorDiv = anchorElement;
};
setAnchorsRef = (anchorsElement: HTMLDivElement) => {
this.anchorsDiv = anchorsElement;
};
setConnectionsSVGRef = (connectionsSVG: SVGElement) => {
this.connectionsSVG = connectionsSVG;
};
setConnectionLineRef = (connectionLine: SVGLineElement) => {
this.connectionLine = connectionLine;
};
setConnectionVertexRef = (connectionVertex: SVGCircleElement) => {
this.connectionVertex = connectionVertex;
};
setConnectionVertexPathRef = (connectionVertexPath: SVGPathElement) => {
this.connectionVertexPath = connectionVertexPath;
};
// Recursively find the first parent that is a canvas element
findElementTarget = (element: Element): ElementState | undefined => {
let elementTarget = undefined;
// Cap recursion at the scene level
if (element === this.scene.viewportDiv) {
return undefined;
}
elementTarget = findElementByTarget(element, this.scene.root.elements);
if (!elementTarget && element.parentElement) {
elementTarget = this.findElementTarget(element.parentElement);
}
return elementTarget;
};
handleMouseEnter = (event: React.MouseEvent) => {
if (!(event.target instanceof Element) || !this.scene.isEditingEnabled) {
return;
}
let element: ElementState | undefined = this.findElementTarget(event.target);
if (!element) {
console.log('no element');
return;
}
if (this.isDrawingConnection) {
this.connectionTarget = element;
} else {
this.connectionSource = element;
if (!this.connectionSource) {
console.log('no connection source');
return;
}
}
const customElementAnchors = element?.item.customConnectionAnchors || ANCHORS;
// This type cast is necessary as TS doesn't understand that `Element` is an `HTMLElement`
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const anchors = Array.from(this.anchorsDiv?.children as HTMLCollectionOf<HTMLElement>);
const anchorsAmount = customElementAnchors.length;
// re-calculate the position of the existing anchors on hover
// and hide the rest of the anchors if there are more than the custom ones
anchors.forEach((anchor, index) => {
if (index >= anchorsAmount) {
anchor.style.display = 'none';
} else {
const { x, y } = customElementAnchors[index];
anchor.style.top = `calc(${-y * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`;
anchor.style.left = `calc(${x * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`;
anchor.style.display = 'block';
}
});
const { top, left, width, height, rotation } = getElementTransformAndDimensions(element.div!);
if (this.connectionAnchorDiv) {
this.connectionAnchorDiv.style.display = 'none';
this.connectionAnchorDiv.style.display = 'block';
this.connectionAnchorDiv.style.transform = `translate(${left}px, ${top}px) rotate(${rotation}deg)`;
this.connectionAnchorDiv.style.height = `${height}px`;
this.connectionAnchorDiv.style.width = `${width}px`;
}
};
// Return boolean indicates if connection anchors were hidden or not
handleMouseLeave = (event: React.MouseEvent | React.FocusEvent): boolean => {
// If mouse is leaving INTO the anchor image, don't remove div
if (
event.relatedTarget instanceof HTMLImageElement &&
event.relatedTarget.getAttribute('alt') === CONNECTION_ANCHOR_ALT
) {
return false;
}
this.connectionTarget = undefined;
this.connectionAnchorDiv!.style.display = 'none';
return true;
};
connectionListener = (event: MouseEvent) => {
event.preventDefault();
if (!(this.connectionLine && this.scene.viewportDiv && this.scene.viewportDiv.parentElement)) {
return;
}
const { scale } = this.scene;
const parentBoundingRect = getParentBoundingClientRect(this.scene);
if (!parentBoundingRect) {
return;
}
const x = (event.pageX - parentBoundingRect.x) / scale;
const y = (event.pageY - parentBoundingRect.y) / scale;
this.connectionLine.setAttribute('x2', `${x}`);
this.connectionLine.setAttribute('y2', `${y}`);
const connectionLineX1 = this.connectionLine.x1.baseVal.value;
const connectionLineY1 = this.connectionLine.y1.baseVal.value;
if (!this.didConnectionLeaveHighlight) {
const connectionLength = Math.hypot(x - connectionLineX1, y - connectionLineY1);
if (connectionLength > CONNECTION_ANCHOR_HIGHLIGHT_OFFSET) {
this.didConnectionLeaveHighlight = true;
this.connectionLine.style.display = 'block';
this.isDrawingConnection = true;
}
}
//
// SAVING CONNECTION
//
if (!event.buttons) {
if (this.connectionSource && this.connectionSource.div && this.connectionSource.div.parentElement) {
const { x: sourceX, y: sourceY } = getNormalizedRotatedOffset(
this.connectionSource.div,
connectionLineX1,
connectionLineY1
);
let targetX;
let targetY;
let targetName;
if (this.connectionTarget && this.connectionTarget.div) {
({ x: targetX, y: targetY } = getNormalizedRotatedOffset(this.connectionTarget.div, x, y));
targetName = this.connectionTarget.options.name;
} else {
// if there is no target (open connection)
targetX = x;
targetY = y;
}
const connection = {
source: {
x: sourceX,
y: sourceY,
},
target: {
x: targetX,
y: targetY,
},
targetName: targetName,
color: {
fixed: config.theme2.colors.text.primary,
},
size: {
fixed: 2,
min: 1,
max: 10,
},
path: ConnectionPath.Straight,
};
const { options } = this.connectionSource;
if (!options.connections) {
options.connections = [];
}
if (this.didConnectionLeaveHighlight) {
this.connectionSource.options.connections = [...options.connections, connection];
this.connectionSource.onChange(this.connectionSource.options);
}
}
if (this.connectionLine) {
this.connectionLine.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;
this.updateState();
this.scene.save();
}
};
// Handles mousemove and mouseup events when dragging an existing vertex
vertexListener = (event: MouseEvent) => {
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
event.preventDefault();
if (!(this.connectionVertex && this.scene.viewportDiv && this.scene.viewportDiv.parentElement)) {
return;
}
const parentBoundingRect = getParentBoundingClientRect(this.scene);
if (!parentBoundingRect) {
return;
}
const { scale } = this.scene;
const x = (event.pageX - parentBoundingRect.x) / scale;
const y = (event.pageY - parentBoundingRect.y) / scale;
this.connectionVertex?.setAttribute('cx', `${x}`);
this.connectionVertex?.setAttribute('cy', `${y}`);
const selectedValue = this.selection.value;
const { x1, y1, x2, y2 } = calculateCoordinates2(
selectedValue!.source,
selectedValue!.target,
selectedValue?.info!
);
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
if (selectedValue?.sourceOriginal && selectedValue.targetOriginal) {
xStart = selectedValue.sourceOriginal.x;
yStart = selectedValue.sourceOriginal.y;
xEnd = selectedValue.targetOriginal.x;
yEnd = selectedValue.targetOriginal.y;
}
const xDist = xEnd - xStart;
const yDist = yEnd - yStart;
let vx1 = x1;
let vy1 = y1;
let vx2 = x2;
let vy2 = y2;
if (selectedValue && selectedValue.vertices) {
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) {
vx1 = selectedValue.vertices[this.selectedVertexIndex - 1].x * xDist + xStart;
vy1 = selectedValue.vertices[this.selectedVertexIndex - 1].y * yDist + yStart;
}
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < selectedValue.vertices.length - 1) {
vx2 = selectedValue.vertices[this.selectedVertexIndex + 1].x * xDist + xStart;
vy2 = selectedValue.vertices[this.selectedVertexIndex + 1].y * yDist + yStart;
}
}
// Check if slope before vertex and after vertex is within snapping tolerance
let xSnap = x;
let ySnap = y;
let deleteVertex = false;
// Ignore if control key being held
if (!event.ctrlKey) {
// Check if segment before and after vertex are close to vertical or horizontal
const verticalBefore = Math.abs((xSnap - vx1) / (ySnap - vy1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
const verticalAfter = Math.abs((xSnap - vx2) / (ySnap - vy2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
const horizontalBefore = Math.abs((ySnap - vy1) / (xSnap - vx1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
const horizontalAfter = Math.abs((ySnap - vy2) / (xSnap - vx2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
if (verticalBefore) {
xSnap = vx1;
} else if (verticalAfter) {
xSnap = vx2;
}
if (horizontalBefore) {
ySnap = vy1;
} else if (horizontalAfter) {
ySnap = vy2;
}
if ((verticalBefore || verticalAfter) && (horizontalBefore || horizontalAfter)) {
this.scene.selecto!.rootContainer!.style.cursor = 'move';
} else if (verticalBefore || verticalAfter) {
this.scene.selecto!.rootContainer!.style.cursor = 'col-resize';
} else if (horizontalBefore || horizontalAfter) {
this.scene.selecto!.rootContainer!.style.cursor = 'row-resize';
}
const angleOverall = calculateAngle(vx1, vy1, vx2, vy2);
const angleBefore = calculateAngle(vx1, vy1, x, y);
deleteVertex = Math.abs(angleBefore - angleOverall) < CONNECTION_VERTEX_SNAP_TOLERANCE;
}
if (deleteVertex) {
// Display temporary vertex removal
this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${vx2} ${vy2}`);
this.connectionVertexPath!.style.display = 'block';
this.connectionVertex.style.display = 'none';
} else {
// Display temporary vertex during drag
this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${xSnap} ${ySnap} L${vx2} ${vy2}`);
this.connectionVertexPath!.style.display = 'block';
this.connectionVertex.style.display = 'block';
}
// Handle mouseup
if (!event.buttons) {
// Remove existing event listener
this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexListener);
this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexListener);
this.scene.selecto!.rootContainer!.style.cursor = 'auto';
this.connectionVertexPath!.style.display = 'none';
this.connectionVertex.style.display = 'none';
// call onChange here and update appropriate index of connection vertices array
const connectionIndex = selectedValue?.index;
const vertexIndex = this.selectedVertexIndex;
if (connectionIndex !== undefined && vertexIndex !== undefined) {
const currentSource = selectedValue!.source;
if (currentSource.options.connections) {
const currentConnections = [...currentSource.options.connections];
if (currentConnections[connectionIndex].vertices) {
const currentVertices = [...currentConnections[connectionIndex].vertices!];
// TODO for vertex removal, clear out originals?
if (deleteVertex) {
currentVertices.splice(vertexIndex, 1);
} else {
const currentVertex = { ...currentVertices[vertexIndex] };
currentVertex.x = (xSnap - xStart) / xDist;
currentVertex.y = (ySnap - yStart) / yDist;
currentVertices[vertexIndex] = currentVertex;
}
currentConnections[connectionIndex] = {
...currentConnections[connectionIndex],
vertices: currentVertices,
};
// Update save model
currentSource.onChange({ ...currentSource.options, connections: currentConnections });
this.updateState();
this.scene.save();
}
}
}
}
};
// Handles mousemove and mouseup events when dragging a new vertex
vertexAddListener = (event: MouseEvent) => {
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
event.preventDefault();
if (!(this.connectionVertex && this.scene.viewportDiv && this.scene.viewportDiv.parentElement)) {
return;
}
const parentBoundingRect = getParentBoundingClientRect(this.scene);
if (!parentBoundingRect) {
return;
}
const { scale } = this.scene;
const x = (event.pageX - parentBoundingRect.x) / scale;
const y = (event.pageY - parentBoundingRect.y) / scale;
this.connectionVertex?.setAttribute('cx', `${x}`);
this.connectionVertex?.setAttribute('cy', `${y}`);
const selectedValue = this.selection.value;
const { x1, y1, x2, y2 } = calculateCoordinates2(
selectedValue!.source,
selectedValue!.target,
selectedValue?.info!
);
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
if (selectedValue?.sourceOriginal && selectedValue.targetOriginal) {
xStart = selectedValue.sourceOriginal.x;
yStart = selectedValue.sourceOriginal.y;
xEnd = selectedValue.targetOriginal.x;
yEnd = selectedValue.targetOriginal.y;
}
const xDist = xEnd - xStart;
const yDist = yEnd - yStart;
let vx1 = x1;
let vy1 = y1;
let vx2 = x2;
let vy2 = y2;
if (selectedValue && selectedValue.vertices) {
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) {
vx1 = selectedValue.vertices[this.selectedVertexIndex - 1].x * xDist + xStart;
vy1 = selectedValue.vertices[this.selectedVertexIndex - 1].y * yDist + yStart;
}
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < selectedValue.vertices.length) {
vx2 = selectedValue.vertices[this.selectedVertexIndex].x * xDist + xStart;
vy2 = selectedValue.vertices[this.selectedVertexIndex].y * yDist + yStart;
}
}
// Check if slope before vertex and after vertex is within snapping tolerance
let xSnap = x;
let ySnap = y;
// Ignore if control key being held
if (!event.ctrlKey) {
// Check if segment before and after vertex are close to vertical or horizontal
const verticalBefore = Math.abs((xSnap - vx1) / (ySnap - vy1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
const verticalAfter = Math.abs((xSnap - vx2) / (ySnap - vy2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
const horizontalBefore = Math.abs((ySnap - vy1) / (xSnap - vx1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
const horizontalAfter = Math.abs((ySnap - vy2) / (xSnap - vx2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
if (verticalBefore) {
xSnap = vx1;
} else if (verticalAfter) {
xSnap = vx2;
}
if (horizontalBefore) {
ySnap = vy1;
} else if (horizontalAfter) {
ySnap = vy2;
}
if ((verticalBefore || verticalAfter) && (horizontalBefore || horizontalAfter)) {
this.scene.selecto!.rootContainer!.style.cursor = 'move';
} else if (verticalBefore || verticalAfter) {
this.scene.selecto!.rootContainer!.style.cursor = 'col-resize';
} else if (horizontalBefore || horizontalAfter) {
this.scene.selecto!.rootContainer!.style.cursor = 'row-resize';
}
}
this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${xSnap} ${ySnap} L${vx2} ${vy2}`);
this.connectionVertexPath!.style.display = 'block';
this.connectionVertex.style.display = 'block';
// Handle mouseup
if (!event.buttons) {
// Remove existing event listener
this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexAddListener);
this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexAddListener);
this.scene.selecto!.rootContainer!.style.cursor = 'auto';
this.connectionVertexPath!.style.display = 'none';
this.connectionVertex.style.display = 'none';
// call onChange here and insert new vertex at appropriate index of connection vertices array
const connectionIndex = selectedValue?.index;
const vertexIndex = this.selectedVertexIndex;
if (connectionIndex !== undefined && vertexIndex !== undefined) {
const currentSource = selectedValue!.source;
if (currentSource.options.connections) {
const currentConnections = [...currentSource.options.connections];
// Calculate normalized coordinates for the new vertex, using rotatedX/Y
const newVertex = { x: (xSnap - xStart) / xDist, y: (ySnap - yStart) / yDist };
if (currentConnections[connectionIndex].vertices) {
const currentVertices = [...currentConnections[connectionIndex].vertices!];
currentVertices.splice(vertexIndex, 0, newVertex);
currentConnections[connectionIndex] = {
...currentConnections[connectionIndex],
vertices: currentVertices,
};
} else {
// For first vertex creation
const currentVertices: ConnectionCoordinates[] = [newVertex];
currentConnections[connectionIndex] = {
...currentConnections[connectionIndex],
vertices: currentVertices,
};
}
// Check for original state
if (
!currentConnections[connectionIndex].sourceOriginal ||
!currentConnections[connectionIndex].targetOriginal
) {
currentConnections[connectionIndex] = {
...currentConnections[connectionIndex],
sourceOriginal: { x: x1, y: y1 },
targetOriginal: { x: x2, y: y2 },
};
}
// Update save model
currentSource.onChange({ ...currentSource.options, connections: currentConnections });
this.updateState();
this.scene.save();
}
}
}
};
handleConnectionDragStart = (selectedTarget: HTMLElement, clientX: number, clientY: number) => {
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
if (this.connectionLine && this.scene.viewportDiv && this.scene.viewportDiv.parentElement) {
const connectionStartTargetBox = selectedTarget.getBoundingClientRect();
const { scale } = this.scene;
const parentBoundingRect = getParentBoundingClientRect(this.scene);
if (!parentBoundingRect) {
return;
}
// Multiply by transform scale to calculate the correct scaled offset
const connectionAnchorOffsetX = CONNECTION_ANCHOR_HIGHLIGHT_OFFSET * scale;
const connectionAnchorOffsetY = CONNECTION_ANCHOR_HIGHLIGHT_OFFSET * scale;
const x = (connectionStartTargetBox.x - parentBoundingRect.x + connectionAnchorOffsetX) / scale;
const y = (connectionStartTargetBox.y - parentBoundingRect.y + connectionAnchorOffsetY) / scale;
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.didConnectionLeaveHighlight = false;
}
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener);
};
// Add event listener at root container during existing vertex drag
handleVertexDragStart = (selectedTarget: HTMLElement) => {
// Get vertex index from selected target data
this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index'));
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexListener);
this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexListener);
};
// Add event listener at root container during creation of new vertex
handleVertexAddDragStart = (selectedTarget: HTMLElement) => {
// Get vertex index from selected target data
this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index'));
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexAddListener);
this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexAddListener);
};
onChange = (current: ConnectionState, update: CanvasConnection) => {
const connections = current.source.options.connections?.splice(0) ?? [];
connections[current.index] = update;
current.source.onChange({ ...current.source.options, connections });
this.updateState();
};
// used for moveable actions
connectionsNeedUpdate = (element: ElementState): boolean => {
return isConnectionSource(element) || isConnectionTarget(element, this.scene.byName);
};
render() {
return (
<>
<ConnectionAnchors
setRef={this.setConnectionAnchorRef}
setAnchorsRef={this.setAnchorsRef}
handleMouseLeave={this.handleMouseLeave}
/>
<ConnectionSVG
setLineRef={this.setConnectionLineRef}
setVertexPathRef={this.setConnectionVertexPathRef}
setVertexRef={this.setConnectionVertexRef}
setConnectionsSVGRef={this.setConnectionsSVGRef}
scene={this.scene}
/>
</>
);
}
}
@@ -77,7 +77,7 @@ export function PlacementEditor({ item }: Props) {
const onHorizontalConstraintChange = (h: HorizontalConstraint) => {
element.options.constraint!.horizontal = h;
element.setPlacementFromConstraint();
element.setPlacementFromConstraint(undefined, undefined, settings.scene.scale);
settings.scene.revId++;
settings.scene.save(true);
reselectElementAfterChange();
@@ -89,7 +89,7 @@ export function PlacementEditor({ item }: Props) {
const onVerticalConstraintChange = (v: VerticalConstraint) => {
element.options.constraint!.vertical = v;
element.setPlacementFromConstraint();
element.setPlacementFromConstraint(undefined, undefined, settings.scene.scale);
settings.scene.revId++;
settings.scene.save(true);
reselectElementAfterChange();
+5 -9
View File
@@ -36,7 +36,7 @@ export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilde
category,
description: t('canvas.description-pan-zoom', 'Enable pan and zoom'),
defaultValue: false,
showIf: (opts) => config.featureToggles.canvasPanelPanZoom,
showIf: () => config.featureToggles.canvasPanelPanZoom,
});
builder.addCustomEditor({
id: 'panZoomHelp',
@@ -47,15 +47,11 @@ export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilde
showIf: (opts) => config.featureToggles.canvasPanelPanZoom && opts.panZoom,
});
builder.addBooleanSwitch({
path: 'infinitePan',
name: t('canvas.name-infinite-panning', 'Infinite panning'),
category,
description: t(
'canvas.description-infinite-panning',
'Enable infinite panning - useful for expansive canvases. Warning: this is an experimental feature and currently only works well with elements that are top / left constrained'
),
path: 'zoomToContent',
name: 'Zoom to content',
description: 'Automatically zoom to fit content',
defaultValue: false,
showIf: (opts) => config.featureToggles.canvasPanelPanZoom && opts.panZoom,
showIf: () => config.featureToggles.canvasPanelPanZoom,
});
category = [t('canvas.category-tooltip', 'Tooltip')];
+2 -2
View File
@@ -99,8 +99,8 @@ composableKinds: PanelCfg: {
showAdvancedTypes: bool | *true
// Enable pan and zoom
panZoom: bool | *true
// Enable infinite pan
infinitePan: bool | *true
// Zoom to content
zoomToContent: bool | *true
// The root element of canvas (frame), where all canvas elements are nested
// TODO: Figure out how to define a default value for this
root: {
@@ -115,10 +115,6 @@ export interface CanvasTooltip {
}
export interface Options {
/**
* Enable infinite pan
*/
infinitePan: boolean;
/**
* Enable inline editing
*/
@@ -153,11 +149,15 @@ export interface Options {
* Controls tooltip options
*/
tooltip: CanvasTooltip;
/**
* Zoom to content
*/
zoomToContent: boolean;
}
export const defaultOptions: Partial<Options> = {
infinitePan: true,
inlineEditing: true,
panZoom: true,
showAdvancedTypes: true,
zoomToContent: true,
};
+107 -16
View File
@@ -204,6 +204,100 @@ export const calculateCoordinates = (
return { x1, y1, x2, y2 };
};
export const calculateCoordinates2 = (source: ElementState, target: ElementState, info: CanvasConnection) => {
const { x: x1, y: y1 } = getRotatedConnectionPoint(source.div!, info.source.x, info.source.y);
let x2 = 0;
let y2 = 0;
const targetDiv = target.div;
if (info.targetName && targetDiv) {
({ x: x2, y: y2 } = getRotatedConnectionPoint(targetDiv, info.target.x, info.target.y));
} else {
x2 = info.target.x;
y2 = info.target.y;
}
return { x1, y1, x2, y2 };
};
export const getElementTransformAndDimensions = (element: Element) => {
const style = window.getComputedStyle(element);
const transform = style.transform;
let x = 0;
let y = 0;
let rotation = 0;
if (transform !== 'none') {
// Use DOMMatrix to parse the transform string
const matrix = new DOMMatrix(transform);
// Extract x and y values
x = matrix.m41;
y = matrix.m42;
// Extract rotation in radians and convert to degrees
// For 2D transforms, rotation = atan2(m21, m11)
rotation = -Math.atan2(matrix.m21, matrix.m11) * (180 / Math.PI);
}
// Get the width and height of the element
// TODO: there sould be a better way than parseFloat
const width = parseFloat(style.width);
const height = parseFloat(style.height);
return { left: x, top: y, width, height, x, y, rotation };
};
export const getNormalizedRotatedOffset = (div: HTMLDivElement, x: number, y: number) => {
const { left, top, width, height, rotation } = getElementTransformAndDimensions(div);
// Calculate center of source element
const centerX = left + width / 2;
const centerY = top + height / 2;
// Calculate the offset from the center to the connection start point
let dx = x - centerX;
let dy = y - centerY;
// Adjust for rotation
const rad = rotation * (Math.PI / 180);
const cos = Math.cos(-rad);
const sin = Math.sin(-rad);
// Rotate the delta by the negative of the element's rotation
const rotatedDx = dx * cos - dy * sin;
const rotatedDy = dx * sin + dy * cos;
// Convert to normalized coordinates
const normalizedX = rotatedDx / (width / 2);
const normalizedY = -rotatedDy / (height / 2);
return { x: normalizedX, y: normalizedY };
};
export const getRotatedConnectionPoint = (div: HTMLDivElement, normalizedX: number, normalizedY: number) => {
const { left, top, width, height, rotation } = getElementTransformAndDimensions(div);
const centerX = left + width / 2;
const centerY = top + height / 2;
// Calculate offset from center before rotation
const offsetX = (normalizedX * width) / 2;
const offsetY = -(normalizedY * height) / 2;
// Convert rotation to radians
const rad = rotation * (Math.PI / 180);
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// Apply rotation to offset
const rotatedOffsetX = offsetX * cos - offsetY * sin;
const rotatedOffsetY = offsetX * sin + offsetY * cos;
const x = centerX + rotatedOffsetX;
const y = centerY + rotatedOffsetY;
return { x, y };
};
export const calculateMidpoint = (x1: number, y1: number, x2: number, y2: number) => {
return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
};
@@ -273,27 +367,12 @@ const getLineStyle = (lineStyle?: LineStyle) => {
export const getParentBoundingClientRect = (scene: Scene) => {
if (config.featureToggles.canvasPanelPanZoom) {
const transformRef = scene.transformComponentRef?.current;
return transformRef?.instance.contentComponent?.getBoundingClientRect();
return scene.viewportDiv?.getBoundingClientRect();
}
return scene.div?.getBoundingClientRect();
};
export const getTransformInstance = (scene: Scene) => {
if (config.featureToggles.canvasPanelPanZoom) {
return scene.transformComponentRef?.current?.instance;
}
return undefined;
};
export const getParent = (scene: Scene) => {
if (config.featureToggles.canvasPanelPanZoom) {
return scene.transformComponentRef?.current?.instance.contentComponent;
}
return scene.div;
};
export function getElementFields(frames: DataFrame[], opts: CanvasElementOptions) {
const fields = new Set<Field>();
const cfg = opts.config ?? {};
@@ -327,3 +406,15 @@ export function getElementFields(frames: DataFrame[], opts: CanvasElementOptions
return [...fields];
}
export function applyStyles(styles: React.CSSProperties, target: HTMLDivElement) {
// INFO: CSSProperties can't be applied using setProperty, so we use Object.assign
Object.assign(target.style, styles);
}
export function removeStyles(styles: React.CSSProperties, target: HTMLDivElement) {
for (const key in styles) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
target.style[key as any] = '';
}
}
-2
View File
@@ -3758,7 +3758,6 @@
}
},
"description-experimental-types": "Enable selection of experimental element types",
"description-infinite-panning": "Enable infinite panning - useful for expansive canvases. Warning: this is an experimental feature and currently only works well with elements that are top / left constrained",
"description-inline-editing": "Enable editing the panel directly",
"description-pan-zoom": "Enable pan and zoom",
"direction-options": {
@@ -3859,7 +3858,6 @@
"name-align-text": "Align text",
"name-color": "Text color",
"name-experimental-types": "Experimental element types",
"name-infinite-panning": "Infinite panning",
"name-inline-editing": "Inline editing",
"name-pan-zoom": "Pan and zoom",
"name-text": "Text",
+16 -12
View File
@@ -17645,7 +17645,7 @@ __metadata:
languageName: node
linkType: hard
"gesto@npm:^1.19.3, gesto@npm:^1.19.4":
"gesto@npm:^1.19.1, gesto@npm:^1.19.3, gesto@npm:^1.19.4":
version: 1.19.4
resolution: "gesto@npm:1.19.4"
dependencies:
@@ -18409,6 +18409,7 @@ __metadata:
i18next-pseudo: "npm:^2.2.1"
immer: "npm:10.1.1"
immutable: "npm:5.1.3"
infinite-viewer: "npm:^0.29.1"
ini: "npm:^5.0.0"
ix: "npm:^7.0.0"
jest: "npm:29.7.0"
@@ -18497,7 +18498,6 @@ __metadata:
react-virtualized-auto-sizer: "npm:1.0.26"
react-window: "npm:1.8.11"
react-window-infinite-loader: "npm:1.0.10"
react-zoom-pan-pinch: "npm:^3.3.0"
reduce-reducers: "npm:^1.0.4"
redux: "npm:5.0.1"
redux-mock-store: "npm:1.5.5"
@@ -19519,6 +19519,20 @@ __metadata:
languageName: node
linkType: hard
"infinite-viewer@npm:^0.29.1":
version: 0.29.1
resolution: "infinite-viewer@npm:0.29.1"
dependencies:
"@daybrush/utils": "npm:^1.13.0"
"@egjs/agent": "npm:^2.2.1"
"@scena/event-emitter": "npm:^1.0.5"
css-styled: "npm:^1.0.8"
framework-utils: "npm:^1.1.0"
gesto: "npm:^1.19.1"
checksum: 10/a3aefd8e3e3ee083e849495e8664cf2b2de4a9039cc3e4d07650ffdb404298b2f30c55b701965c490e4f75cf5d0dfcfa18abe83ff225a8845ebde7e7b91be117
languageName: node
linkType: hard
"inflight@npm:^1.0.4":
version: 1.0.6
resolution: "inflight@npm:1.0.6"
@@ -27633,16 +27647,6 @@ __metadata:
languageName: node
linkType: hard
"react-zoom-pan-pinch@npm:^3.3.0":
version: 3.7.0
resolution: "react-zoom-pan-pinch@npm:3.7.0"
peerDependencies:
react: "*"
react-dom: "*"
checksum: 10/5ae7f1ffea86fd19ae57f7b4c6818b282e13e00523200d23759db95a1518333044017583c2af3c179b2634c88c76162aa0ce8de79cd26359f07339415842ba34
languageName: node
linkType: hard
"react@npm:18.3.1":
version: 18.3.1
resolution: "react@npm:18.3.1"