diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go index 50847df87c3..a267e0c8df8 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go @@ -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{} diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go index 1423c7b0603..be021b5f003 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go @@ -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{} diff --git a/e2e/plugin-e2e/canvas/canvas-scene.spec.ts b/e2e/plugin-e2e/canvas/canvas-scene.spec.ts new file mode 100644 index 00000000000..b640f450223 --- /dev/null +++ b/e2e/plugin-e2e/canvas/canvas-scene.spec.ts @@ -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 { + 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 + ); +} diff --git a/package.json b/package.json index 30e89988978..8a70fc05731 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts index 722317c115a..997afed4975 100644 --- a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts @@ -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 = { - infinitePan: true, inlineEditing: true, panZoom: true, showAdvancedTypes: true, + zoomToContent: true, }; diff --git a/playwright.config.ts b/playwright.config.ts index a3388e20c5e..e1a6b654e73 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -195,6 +195,15 @@ export default defineConfig({ }, 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'), diff --git a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx deleted file mode 100644 index eed1b429193..00000000000 --- a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx +++ /dev/null @@ -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) => { - // 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 ( - - - {/* The
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 */} -
{sceneDiv}
- - - ); -}; diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index d06c7d5caee..a64d004967b 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -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' }} > 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(); - transformComponentRef: RefObject | 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 = ( -
+ <> {this.connections.render()} {this.root.render()} {this.isEditingEnabled && ( @@ -312,13 +394,30 @@ export class Scene { )} -
+ ); return config.featureToggles.canvasPanelPanZoom ? ( - {sceneDiv} +
+
+ {sceneDiv} +
+
) : ( - sceneDiv +
+ {sceneDiv} +
); } } @@ -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%', + }), }); diff --git a/public/app/features/canvas/runtime/sceneAbleManagement.ts b/public/app/features/canvas/runtime/sceneAbleManagement.ts index 0befab40297..9af3e1a74e0 100644 --- a/public/app/features/canvas/runtime/sceneAbleManagement.ts +++ b/public/app/features/canvas/runtime/sceneAbleManagement.ts @@ -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, + }; +} diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 3af277aa833..bf19bd867f8 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -49,6 +49,7 @@ export class CanvasPanel extends Component { 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 { 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 { } }); - 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 { ); } + // 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 { // 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 { 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) { diff --git a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx index 1e53f408bf7..be667771d56 100644 --- a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx @@ -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, { diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors2.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors2.tsx new file mode 100644 index 00000000000..b79102d3d57 --- /dev/null +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors2.tsx @@ -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 | React.FocusEvent + ) => 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(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 | React.FocusEvent + ) => { + 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 ( + { + 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 ( +
+
+
+
{generateAnchors()}
+
+ ); +}; + +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`, + }), +}); diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx new file mode 100644 index 00000000000..6fc4bf81b5e --- /dev/null +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG2.tsx @@ -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(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 ( + selectConnection(v)}> + + + + + + + + + {vertices?.length ? ( + // Render path with vertices + + {/* heighlight line */} + + {/* real line */} + + {shouldAnimate && ( + + )} + + {isSelected && ( + + {/* vertices */} + {vertices.map((value, index) => { + return ( + + ); + })} + {/* midpoints */} + {vertices.length < maximumVertices && + addVertices.map((value, index) => { + return ( + + ); + })} + + )} + + ) : ( + // Render line without vertices + + {/* heighlight line */} + + {/* real line */} + + {shouldAnimate && ( + + )} + + {/* initial midpoint */} + {isSelected && ( + + )} + + )} + + ); + }) + ); + }; + + return ( + <> + + + + + + + {/* svg line for connection creation */} + + + {/* svg circle for initial vertex? + path? is it for the line drag handling? */} + + + + {renderConnections()} + + + ); +}; + +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, + }), +}); diff --git a/public/app/plugins/panel/canvas/components/connections/Connections.tsx b/public/app/plugins/panel/canvas/components/connections/Connections.tsx index 3e402dfe47d..500275560ef 100644 --- a/public/app/plugins/panel/canvas/components/connections/Connections.tsx +++ b/public/app/plugins/panel/canvas/components/connections/Connections.tsx @@ -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; diff --git a/public/app/plugins/panel/canvas/components/connections/Connections2.tsx b/public/app/plugins/panel/canvas/components/connections/Connections2.tsx new file mode 100644 index 00000000000..4c7bfdadd11 --- /dev/null +++ b/public/app/plugins/panel/canvas/components/connections/Connections2.tsx @@ -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(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); + 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 ( + <> + + + + ); + } +} diff --git a/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx b/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx index 05933ad61c0..09de5324966 100644 --- a/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx @@ -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(); diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx index 512509841ca..a3677254ed0 100644 --- a/public/app/plugins/panel/canvas/module.tsx +++ b/public/app/plugins/panel/canvas/module.tsx @@ -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')]; diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index f32837ea1e8..2e83845a0d8 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -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: { diff --git a/public/app/plugins/panel/canvas/panelcfg.gen.ts b/public/app/plugins/panel/canvas/panelcfg.gen.ts index be425f6c764..0dde39f6ac4 100644 --- a/public/app/plugins/panel/canvas/panelcfg.gen.ts +++ b/public/app/plugins/panel/canvas/panelcfg.gen.ts @@ -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 = { - infinitePan: true, inlineEditing: true, panZoom: true, showAdvancedTypes: true, + zoomToContent: true, }; diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 386466d3378..80b83e5415e 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -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(); 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] = ''; + } +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 25f2393bb35..c0061d4b664 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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", diff --git a/yarn.lock b/yarn.lock index da0a0e16caf..4fd15247adf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"