From 2502fe4d19faa993da8fd4d21b9802d59f4b02af Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Tue, 2 Jan 2024 11:52:21 -0800 Subject: [PATCH] Canvas: Add Pan and Zoom (#76705) * Canvas: Add Zoom * Scale selecto components based on zoom state * Fix pan by reverting to 3.1.0 for zoom-pan * Update to latest library that fixes pan regression * Add mini map to canvas pan zoom * Fix selecto and anchors on hover * Update naming to be more clear * Switch back to contentComponent * Apply transformScale to drag and resize * Update connection source and target scaling * Add option to display mini map * Update yarn lock * Revert "Update yarn lock" This reverts commit 3d1dd65d5726fb0fd0813347451884a4034ae5d3. * Set yarn lock to main * Revert "Set yarn lock to main" This reverts commit 64bc50557e75657fae14f81077d1d08b4e9e9029. * Update to Yarn 4 * Add react-zoom-pan-pinch * Update react-zoom-pan checksum * Revert changes to json files * Remove last line of api merged * Remove last lines of all impacted jsons * Update home json * Update coordinate calc function to include scale * Fix types in coordinate calc function * Fix util calculation for transform * Fix arrow anchor shift behavior * Fix scale offset when adding elements during zoom * Fix drag of selected group during zoom * Add feature flag for canvas pan zoom * Revert "Add feature flag for canvas pan zoom" This reverts commit b026e31d8d9ed64b1fe307f852df10292fffadf4. * Regenerate feature flag after merge * Apply feature flag to enable pan zoom wrappers * Add mini map toggle behind feature flag * Simplify minimap behavior * Update feature flag registry * Set minimap to false by default * fix gen-cue * Set toggles gen to main Add blank line to toggle gen csv * Add canvas pan zoom to csv * Remove old comment * Change ref parameter to be more descriptive * Rename visibleFun to be more descriptive * Consolidate transformScale transformRef in util * Remove non-null assertion on connection parentRect * Consolidate parentRect null coalescing into object * Remove minimap and change toggle * Add controls inline help for pan and zoom * Clean up mouse events * Pull scale out of ref and isolate transform * Remove transform ref from scene div * Fix context menu visible behavior * Fix connections and update util functions * Move transform component instance to util * fix backend test * minor updates * Clean up connections / fix minor bug where offset of arrow wasn't being calculated correctly * missed connection code cleanup * cleanup scene code a bit more * actually fix backend test * move eslint disable line closer to actual issue --------- Co-authored-by: nmarrs --- .../canvas/panelcfg/schema-reference.md | 1 + .../feature-toggles/index.md | 1 + package.json | 1 + .../src/types/featureToggles.gen.ts | 1 + .../panelcfg/x/CanvasPanelCfg_types.gen.ts | 5 ++ pkg/services/featuremgmt/registry.go | 8 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../canvas/runtime/SceneTransformWrapper.tsx | 37 +++++++++ .../app/features/canvas/runtime/element.tsx | 20 ++--- public/app/features/canvas/runtime/scene.tsx | 76 ++++++++++++++++--- .../app/plugins/panel/canvas/CanvasPanel.tsx | 11 ++- .../canvas/components/CanvasContextMenu.tsx | 14 ++-- .../components/connections/ConnectionSVG.tsx | 7 +- .../components/connections/Connections.tsx | 61 ++++++++++----- .../panel/canvas/editor/panZoomHelp.tsx | 57 ++++++++++++++ public/app/plugins/panel/canvas/module.tsx | 17 +++++ public/app/plugins/panel/canvas/panelcfg.cue | 2 + .../app/plugins/panel/canvas/panelcfg.gen.ts | 5 ++ public/app/plugins/panel/canvas/utils.ts | 51 +++++++++---- yarn.lock | 11 +++ 21 files changed, 326 insertions(+), 65 deletions(-) create mode 100644 public/app/features/canvas/runtime/SceneTransformWrapper.tsx create mode 100644 public/app/plugins/panel/canvas/editor/panZoomHelp.tsx diff --git a/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md index 90b7312b126..ca4647b16ff 100644 --- a/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md +++ b/docs/sources/developers/kinds/composable/canvas/panelcfg/schema-reference.md @@ -142,6 +142,7 @@ It extends [BaseDimensionConfig](#basedimensionconfig). | Property | Type | Required | Default | Description | |---------------------|-----------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------| | `inlineEditing` | boolean | **Yes** | `true` | Enable inline editing | +| `panZoom` | boolean | **Yes** | `true` | Enable pan and zoom | | `root` | [object](#root) | **Yes** | | The root element of canvas (frame), where all canvas elements are nested
TODO: Figure out how to define a default value for this | | `showAdvancedTypes` | boolean | **Yes** | `true` | Show all available element types | diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 288acc1e889..553823c3986 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -160,6 +160,7 @@ Experimental features might be changed or removed without prior notice. | `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe | | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | | `dashboardScene` | Enables dashboard rendering using scenes for all roles | +| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | | `flameGraphItemCollapsing` | Allow collapsing of flame graph items | | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | diff --git a/package.json b/package.json index fdcc3ffa1ba..3401f9e5676 100644 --- a/package.json +++ b/package.json @@ -397,6 +397,7 @@ "react-virtualized-auto-sizer": "1.0.7", "react-window": "1.8.9", "react-window-infinite-loader": "1.0.9", + "react-zoom-pan-pinch": "^3.3.0", "redux": "4.2.1", "redux-thunk": "2.4.2", "regenerator-runtime": "0.14.0", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8aa8ef51929..20e089f1f90 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -158,6 +158,7 @@ export interface FeatureToggles { panelFilterVariable?: boolean; pdfTables?: boolean; ssoSettingsApi?: boolean; + canvasPanelPanZoom?: boolean; logsInfiniteScrolling?: boolean; flameGraphItemCollapsing?: boolean; alertingDetailsViewV2?: boolean; 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 8a3eaf86e1b..ec11610309c 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 @@ -109,6 +109,10 @@ export interface Options { * Enable inline editing */ inlineEditing: boolean; + /** + * Enable pan and zoom + */ + panZoom: boolean; /** * The root element of canvas (frame), where all canvas elements are nested * TODO: Figure out how to define a default value for this @@ -135,5 +139,6 @@ export interface Options { export const defaultOptions: Partial = { inlineEditing: true, + panZoom: true, showAdvancedTypes: true, }; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3bd54c9b6d3..38a743f6bb7 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1178,6 +1178,14 @@ var ( Owner: identityAccessTeam, Created: time.Date(2023, time.November, 8, 12, 0, 0, 0, time.UTC), }, + { + Name: "canvasPanelPanZoom", + Description: "Allow pan and zoom in canvas panel", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Created: time.Date(2023, time.December, 27, 12, 0, 0, 0, time.UTC), + }, { Name: "logsInfiniteScrolling", Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c4957dcaba4..b803c9d5e30 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -139,6 +139,7 @@ dashboardScene,experimental,@grafana/dashboards-squad,2023-11-13,false,false,fal panelFilterVariable,experimental,@grafana/dashboards-squad,2023-11-03,false,false,false,true pdfTables,privatePreview,@grafana/sharing-squad,2023-11-06,false,false,false,false ssoSettingsApi,experimental,@grafana/identity-access-team,2023-11-08,true,false,false,false +canvasPanelPanZoom,experimental,@grafana/dataviz-squad,2023-12-27,false,false,false,true logsInfiniteScrolling,experimental,@grafana/observability-logs,2023-11-09,false,false,false,true flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,2023-11-09,false,false,false,true alertingDetailsViewV2,experimental,@grafana/alerting-squad,2023-11-09,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 75cec52aff3..c40afd54e5e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -567,6 +567,10 @@ const ( // Enables the SSO settings API FlagSsoSettingsApi = "ssoSettingsApi" + // FlagCanvasPanelPanZoom + // Allow pan and zoom in canvas panel + FlagCanvasPanelPanZoom = "canvasPanelPanZoom" + // FlagLogsInfiniteScrolling // Enables infinite scrolling for the Logs panel in Explore and Dashboards FlagLogsInfiniteScrolling = "logsInfiniteScrolling" diff --git a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx new file mode 100644 index 00000000000..eaa8cdad860 --- /dev/null +++ b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx @@ -0,0 +1,37 @@ +import 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; + if (scene.moveable && scale > 0) { + scene.moveable.zoom = 1 / scale; + } + scene.scale = scale; + }; + + return ( + { + scene.scale = state.scale; + }} + limitToBounds={true} + disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom} + panning={{ allowLeftClickPan: false }} + > + {sceneDiv} + + ); +}; diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 5062d017edd..03f5214916d 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -197,7 +197,7 @@ export class ElementState implements LayerElement { } } - setPlacementFromConstraint(elementContainer?: DOMRect, parentContainer?: DOMRect) { + setPlacementFromConstraint(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) { const { constraint } = this.options; const { vertical, horizontal } = constraint ?? {}; @@ -214,25 +214,25 @@ export class ElementState implements LayerElement { const relativeTop = elementContainer && parentContainer - ? Math.round(elementContainer.top - parentContainer.top - parentBorderWidth) + ? Math.round(elementContainer.top - parentContainer.top - parentBorderWidth) / transformScale : 0; const relativeBottom = elementContainer && parentContainer - ? Math.round(parentContainer.bottom - parentBorderWidth - elementContainer.bottom) + ? Math.round(parentContainer.bottom - parentBorderWidth - elementContainer.bottom) / transformScale : 0; const relativeLeft = elementContainer && parentContainer - ? Math.round(elementContainer.left - parentContainer.left - parentBorderWidth) + ? Math.round(elementContainer.left - parentContainer.left - parentBorderWidth) / transformScale : 0; const relativeRight = elementContainer && parentContainer - ? Math.round(parentContainer.right - parentBorderWidth - elementContainer.right) + ? Math.round(parentContainer.right - parentBorderWidth - elementContainer.right) / transformScale : 0; const placement: Placement = {}; - const width = elementContainer?.width ?? 100; - const height = elementContainer?.height ?? 100; + const width = (elementContainer?.width ?? 100) / transformScale; + const height = (elementContainer?.height ?? 100) / transformScale; switch (vertical) { case VerticalConstraint.Top: @@ -427,12 +427,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) => { + applyResize = (event: OnResize, transformScale = 1) => { const placement = this.options.placement!; const style = event.target.style; - const deltaX = event.delta[0]; - const deltaY = event.delta[1]; + const deltaX = event.delta[0] / transformScale; + const deltaY = event.delta[1] / transformScale; const dirLR = event.direction[0]; const dirTB = event.direction[1]; diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index f58a7232687..1d2aaa63fed 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import Moveable from 'moveable'; -import React, { CSSProperties } from 'react'; +import React, { createRef, CSSProperties, RefObject } from 'react'; +import { ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'; import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import Selecto from 'selecto'; @@ -30,11 +31,13 @@ import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections'; import { AnchorPoint, CanvasTooltipPayload, LayerActionID } from 'app/plugins/panel/canvas/types'; +import { getParent, getTransformInstance } from 'app/plugins/panel/canvas/utils'; import appEvents from '../../../core/app_events'; import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel'; import { HorizontalConstraint, Placement, VerticalConstraint } from '../types'; +import { SceneTransformWrapper } from './SceneTransformWrapper'; import { constraintViewable, dimensionViewable, settingsViewable } from './ables'; import { ElementState } from './element'; import { FrameState } from './frame'; @@ -57,6 +60,7 @@ export class Scene { width = 0; height = 0; + scale = 1; style: CSSProperties = {}; data?: PanelData; selecto?: Selecto; @@ -66,9 +70,22 @@ export class Scene { currentLayer?: FrameState; isEditingEnabled?: boolean; shouldShowAdvancedTypes?: boolean; + shouldPanZoom?: boolean; skipNextSelectionBroadcast = false; ignoreDataUpdate = false; panel: CanvasPanel; + contextMenuVisible?: boolean; + 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; @@ -84,15 +101,17 @@ export class Scene { subscription: Subscription; targetsToSelect = new Set(); + transformComponentRef: RefObject | undefined; constructor( cfg: CanvasFrameOptions, enableEditing: boolean, showAdvancedTypes: boolean, + panZoom: boolean, public onSave: (cfg: CanvasFrameOptions) => void, panel: CanvasPanel ) { - this.root = this.load(cfg, enableEditing, showAdvancedTypes); + this.root = this.load(cfg, enableEditing, showAdvancedTypes, panZoom); this.subscription = this.editModeEnabled.subscribe((open) => { if (!this.moveable || !this.isEditingEnabled) { @@ -103,6 +122,7 @@ export class Scene { this.panel = panel; this.connections = new Connections(this); + this.transformComponentRef = createRef(); } getNextElementName = (isFrame = false) => { @@ -124,7 +144,7 @@ export class Scene { return !this.byName.has(v); }; - load(cfg: CanvasFrameOptions, enableEditing: boolean, showAdvancedTypes: boolean) { + load(cfg: CanvasFrameOptions, enableEditing: boolean, showAdvancedTypes: boolean, panZoom: boolean) { this.root = new RootElement( cfg ?? { type: 'frame', @@ -136,6 +156,7 @@ export class Scene { this.isEditingEnabled = enableEditing; this.shouldShowAdvancedTypes = showAdvancedTypes; + this.shouldPanZoom = panZoom; setTimeout(() => { if (this.div) { @@ -369,7 +390,7 @@ export class Scene { this.selecto = new Selecto({ container: this.div, - rootContainer: this.div, + rootContainer: getParent(this), selectableTargets: targetElements, toggleContinueSelect: 'shift', selectFromInside: false, @@ -439,7 +460,9 @@ export class Scene { e.events.forEach((event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { - targetedElement.setPlacementFromConstraint(); + if (targetedElement) { + targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); + } } }); @@ -449,7 +472,7 @@ export class Scene { .on('dragEnd', (event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { - targetedElement.setPlacementFromConstraint(); + targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } this.moved.next(Date.now()); @@ -465,13 +488,13 @@ export class Scene { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left, }; - targetedElement.setPlacementFromConstraint(); + targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } }) .on('resize', (event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { - targetedElement.applyResize(event); + targetedElement.applyResize(event, this.scale); if (this.connections.connectionsNeedUpdate(targetedElement) && this.moveableActionCallback) { this.moveableActionCallback(true); @@ -507,7 +530,7 @@ export class Scene { targetedElement.tempConstraint = undefined; } - targetedElement.setPlacementFromConstraint(); + targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } }); @@ -646,13 +669,36 @@ export class Scene { const isTooltipValid = (this.tooltip?.element?.data?.links?.length ?? 0) > 0; const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid; - return ( -
+ const sceneDiv = ( + // TODO: Address this eslint error + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{ + // If pan and zoom is disabled and middle mouse or ctrl + right mouse, don't pan + if ((!this.shouldPanZoom || this.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 (!this.contextMenuVisible && e.button === 2 && !e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + } + }} + > {this.connections.render()} {this.root.render()} {canShowContextMenu && ( - + )} {canShowElementTooltip && ( @@ -662,6 +708,12 @@ export class Scene { )}
); + + return config.featureToggles.canvasPanelPanZoom ? ( + {sceneDiv} + ) : ( + sceneDiv + ); } } diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 38838f80913..88cdd6f30a0 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -67,6 +67,7 @@ export class CanvasPanel extends Component { this.props.options.root, this.props.options.inlineEditing, this.props.options.showAdvancedTypes, + this.props.options.panZoom, this.onUpdateScene, this ); @@ -227,14 +228,20 @@ export class CanvasPanel extends Component { const inlineEditingSwitched = this.props.options.inlineEditing !== nextProps.options.inlineEditing; const shouldShowAdvancedTypesSwitched = this.props.options.showAdvancedTypes !== nextProps.options.showAdvancedTypes; - if (this.needsReload || inlineEditingSwitched || shouldShowAdvancedTypesSwitched) { + const panZoomSwitched = this.props.options.panZoom !== nextProps.options.panZoom; + if (this.needsReload || inlineEditingSwitched || shouldShowAdvancedTypesSwitched || panZoomSwitched) { if (inlineEditingSwitched) { // Replace scene div to prevent selecto instance leaks this.scene.revId++; } this.needsReload = false; - this.scene.load(nextProps.options.root, nextProps.options.inlineEditing, nextProps.options.showAdvancedTypes); + this.scene.load( + nextProps.options.root, + nextProps.options.inlineEditing, + nextProps.options.showAdvancedTypes, + nextProps.options.panZoom + ); this.scene.updateSize(nextProps.width, nextProps.height); this.scene.updateData(nextProps.data); changed = true; diff --git a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx index 5d11484f53f..e231d8bc872 100644 --- a/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasContextMenu.tsx @@ -15,9 +15,10 @@ import { getElementTypes, onAddItem } from '../utils'; type Props = { scene: Scene; panel: CanvasPanel; + onVisibilityChange: (v: boolean) => void; }; -export const CanvasContextMenu = ({ scene, panel }: Props) => { +export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) => { const inlineEditorOpen = panel.state.openInlineEdit; const [isMenuVisible, setIsMenuVisible] = useState(false); const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 }); @@ -29,7 +30,7 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => { const handleContextMenu = useCallback( (event: Event) => { - if (!(event instanceof MouseEvent)) { + if (!(event instanceof MouseEvent) || event.ctrlKey) { return; } @@ -45,8 +46,9 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => { } setAnchorPoint({ x: event.pageX, y: event.pageY }); setIsMenuVisible(true); + onVisibilityChange(true); }, - [scene, panel] + [scene, panel, onVisibilityChange] ); useEffect(() => { @@ -65,6 +67,7 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => { const closeContextMenu = () => { setIsMenuVisible(false); + onVisibilityChange(false); }; const renderMenuItems = () => { @@ -114,9 +117,10 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => { let offsetY = anchorPoint.y; let offsetX = anchorPoint.x; if (scene.div) { + const transformScale = scene.scale; const sceneContainerDimensions = scene.div.getBoundingClientRect(); - offsetY = offsetY - sceneContainerDimensions.top; - offsetX = offsetX - sceneContainerDimensions.left; + offsetY = (offsetY - sceneContainerDimensions.top) / transformScale; + offsetX = (offsetX - sceneContainerDimensions.left) / transformScale; } onAddItem(option, rootLayer, { diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index f8c5ece15f6..4506f7b13e2 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -7,7 +7,7 @@ import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionState } from '../../types'; -import { calculateCoordinates, getConnectionStyles } from '../../utils'; +import { calculateCoordinates, getConnectionStyles, getParentBoundingClientRect } from '../../utils'; type Props = { setSVGRef: (anchorElement: SVGSVGElement) => void; @@ -104,13 +104,14 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { const { source, target, info } = v; const sourceRect = source.div?.getBoundingClientRect(); const parent = source.div?.parentElement; - const parentRect = parent?.getBoundingClientRect(); + const transformScale = scene.scale; + const parentRect = getParentBoundingClientRect(scene); if (!sourceRect || !parent || !parentRect) { return; } - const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target); + const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target, transformScale); const { strokeColor, strokeWidth } = getConnectionStyles(info, scene, defaultArrowSize); diff --git a/public/app/plugins/panel/canvas/components/connections/Connections.tsx b/public/app/plugins/panel/canvas/components/connections/Connections.tsx index e3e5ac6e492..0a62a007380 100644 --- a/public/app/plugins/panel/canvas/components/connections/Connections.tsx +++ b/public/app/plugins/panel/canvas/components/connections/Connections.tsx @@ -7,7 +7,7 @@ import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionState } from '../../types'; -import { getConnections, isConnectionSource, isConnectionTarget } from '../../utils'; +import { getConnections, getParentBoundingClientRect, isConnectionSource, isConnectionTarget } from '../../utils'; import { CONNECTION_ANCHOR_ALT, ConnectionAnchors, CONNECTION_ANCHOR_HIGHLIGHT_OFFSET } from './ConnectionAnchors'; import { ConnectionSVG } from './ConnectionSVG'; @@ -103,7 +103,8 @@ export class Connections { } const elementBoundingRect = element.div!.getBoundingClientRect(); - const parentBoundingRect = this.scene.div?.getBoundingClientRect(); + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); const relativeTop = elementBoundingRect.top - (parentBoundingRect?.top ?? 0); const relativeLeft = elementBoundingRect.left - (parentBoundingRect?.left ?? 0); @@ -111,10 +112,10 @@ export class Connections { if (this.connectionAnchorDiv) { this.connectionAnchorDiv.style.display = 'none'; this.connectionAnchorDiv.style.display = 'block'; - this.connectionAnchorDiv.style.top = `${relativeTop}px`; - this.connectionAnchorDiv.style.left = `${relativeLeft}px`; - this.connectionAnchorDiv.style.height = `${elementBoundingRect.height}px`; - this.connectionAnchorDiv.style.width = `${elementBoundingRect.width}px`; + this.connectionAnchorDiv.style.top = `${relativeTop / transformScale}px`; + this.connectionAnchorDiv.style.left = `${relativeLeft / transformScale}px`; + this.connectionAnchorDiv.style.height = `${elementBoundingRect.height / transformScale}px`; + this.connectionAnchorDiv.style.width = `${elementBoundingRect.width / transformScale}px`; } }; @@ -140,12 +141,18 @@ export class Connections { return; } - const parentBoundingRect = this.scene.div.parentElement.getBoundingClientRect(); - const x = event.pageX - parentBoundingRect.x; - const y = event.pageY - parentBoundingRect.y; + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); - this.connectionLine.setAttribute('x2', `${x}`); - this.connectionLine.setAttribute('y2', `${y}`); + if (!parentBoundingRect) { + return; + } + + const x = event.pageX - parentBoundingRect.x ?? 0; + const y = event.pageY - parentBoundingRect.y ?? 0; + + this.connectionLine.setAttribute('x2', `${x / transformScale}`); + this.connectionLine.setAttribute('y2', `${y / transformScale}`); const connectionLineX1 = this.connectionLine.x1.baseVal.value; const connectionLineY1 = this.connectionLine.y1.baseVal.value; @@ -161,15 +168,21 @@ export class Connections { if (!event.buttons) { if (this.connectionSource && this.connectionSource.div && this.connectionSource.div.parentElement) { const sourceRect = this.connectionSource.div.getBoundingClientRect(); - const parentRect = this.connectionSource.div.parentElement.getBoundingClientRect(); - const sourceVerticalCenter = sourceRect.top - parentRect.top + sourceRect.height / 2; - const sourceHorizontalCenter = sourceRect.left - parentRect.left + sourceRect.width / 2; + const transformScale = this.scene.scale; + const parentRect = getParentBoundingClientRect(this.scene); + + if (!parentRect) { + return; + } + + const sourceVerticalCenter = (sourceRect.top - parentRect.top + sourceRect.height / 2) / transformScale; + const sourceHorizontalCenter = (sourceRect.left - parentRect.left + sourceRect.width / 2) / transformScale; // Convert from DOM coords to connection coords // TODO: Break this out into util function and add tests - const sourceX = (connectionLineX1 - sourceHorizontalCenter) / (sourceRect.width / 2); - const sourceY = (sourceVerticalCenter - connectionLineY1) / (sourceRect.height / 2); + const sourceX = (connectionLineX1 - sourceHorizontalCenter) / (sourceRect.width / 2 / transformScale); + const sourceY = (sourceVerticalCenter - connectionLineY1) / (sourceRect.height / 2 / transformScale); let targetX; let targetY; @@ -242,10 +255,20 @@ export class Connections { this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; if (this.connectionSVG && this.connectionLine && this.scene.div && this.scene.div.parentElement) { const connectionStartTargetBox = selectedTarget.getBoundingClientRect(); - const parentBoundingRect = this.scene.div.parentElement.getBoundingClientRect(); - const x = connectionStartTargetBox.x - parentBoundingRect.x + CONNECTION_ANCHOR_HIGHLIGHT_OFFSET; - const y = connectionStartTargetBox.y - parentBoundingRect.y + CONNECTION_ANCHOR_HIGHLIGHT_OFFSET; + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); + + if (!parentBoundingRect) { + return; + } + + // Multiply by transform scale to calculate the correct scaled offset + const connectionAnchorOffsetX = CONNECTION_ANCHOR_HIGHLIGHT_OFFSET * transformScale; + const connectionAnchorOffsetY = CONNECTION_ANCHOR_HIGHLIGHT_OFFSET * transformScale; + + const x = (connectionStartTargetBox.x - parentBoundingRect.x + connectionAnchorOffsetX) / transformScale; + const y = (connectionStartTargetBox.y - parentBoundingRect.y + connectionAnchorOffsetY) / transformScale; const mouseX = clientX - parentBoundingRect.x; const mouseY = clientY - parentBoundingRect.y; diff --git a/public/app/plugins/panel/canvas/editor/panZoomHelp.tsx b/public/app/plugins/panel/canvas/editor/panZoomHelp.tsx new file mode 100644 index 00000000000..14b064ca1e7 --- /dev/null +++ b/public/app/plugins/panel/canvas/editor/panZoomHelp.tsx @@ -0,0 +1,57 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { StandardEditorProps, GrafanaTheme2 } from '@grafana/data'; +import { Alert, HorizontalGroup, Icon, VerticalGroup, useStyles2 } from '@grafana/ui'; + +const helpUrl = 'https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/canvas/'; + +export const PanZoomHelp = ({}: StandardEditorProps) => { + const styles = useStyles2(getStyles); + + return ( + <> + + } + className={styles.alert} + onRemove={() => { + const newWindow = window.open(helpUrl, '_blank', 'noopener,noreferrer'); + if (newWindow) { + newWindow.opener = null; + } + }} + > + +
    +
  • + Pan: +
      +
    • Middle mouse
    • +
    • CTRL + right mouse
    • +
    +
  • +
  • Zoom: Scroll wheel
  • +
  • Reset: Double click
  • +
+
+
+
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + alert: css({ + '& div': { padding: '4px', alignItems: 'start' }, + marginBottom: '0px', + marginTop: '5px', + padding: '2px', + 'ul > li': { marginLeft: '10px' }, + }), + hGroup: css({ + '& div': { width: '100%' }, + }), +}); diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx index 60936b2c12c..96ef9f144db 100644 --- a/public/app/plugins/panel/canvas/module.tsx +++ b/public/app/plugins/panel/canvas/module.tsx @@ -1,10 +1,12 @@ import { FieldConfigProperty, PanelOptionsEditorBuilder, PanelPlugin } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { FrameState } from 'app/features/canvas/runtime/frame'; import { CanvasPanel, InstanceState } from './CanvasPanel'; import { getConnectionEditor } from './editor/connectionEditor'; import { getElementEditor } from './editor/element/elementEditor'; import { getLayerEditor } from './editor/layer/layerEditor'; +import { PanZoomHelp } from './editor/panZoomHelp'; import { canvasMigrationHandler } from './migrations'; import { Options } from './panelcfg.gen'; @@ -22,6 +24,21 @@ export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilde description: 'Enable selection of experimental element types', defaultValue: true, }); + + builder.addBooleanSwitch({ + path: 'panZoom', + name: 'Pan and zoom', + description: 'Enable pan and zoom', + defaultValue: false, + showIf: (opts) => config.featureToggles.canvasPanelPanZoom, + }); + builder.addCustomEditor({ + id: 'panZoomHelp', + path: 'panZoomHelp', + name: '', + editor: PanZoomHelp, + showIf: (opts) => config.featureToggles.canvasPanelPanZoom && opts.panZoom, + }); }; export const plugin = new PanelPlugin(CanvasPanel) diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index 0e0be5a3050..c68db38ff81 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -88,6 +88,8 @@ composableKinds: PanelCfg: { inlineEditing: bool | *true // Show all available element types showAdvancedTypes: bool | *true + // Enable pan and zoom + panZoom: 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 c7dee893e30..880ef5d22db 100644 --- a/public/app/plugins/panel/canvas/panelcfg.gen.ts +++ b/public/app/plugins/panel/canvas/panelcfg.gen.ts @@ -106,6 +106,10 @@ export interface Options { * Enable inline editing */ inlineEditing: boolean; + /** + * Enable pan and zoom + */ + panZoom: boolean; /** * The root element of canvas (frame), where all canvas elements are nested * TODO: Figure out how to define a default value for this @@ -132,5 +136,6 @@ export interface Options { export const defaultOptions: Partial = { inlineEditing: true, + panZoom: true, showAdvancedTypes: true, }; diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 36a7cbac3d6..f55bfa6d1ed 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -190,27 +190,25 @@ export const calculateCoordinates = ( sourceRect: DOMRect, parentRect: DOMRect, info: CanvasConnection, - target: ElementState + target: ElementState, + transformScale: number ) => { const sourceHorizontalCenter = sourceRect.left - parentRect.left + sourceRect.width / 2; const sourceVerticalCenter = sourceRect.top - parentRect.top + sourceRect.height / 2; // Convert from connection coords to DOM coords - const x1 = sourceHorizontalCenter + (info.source.x * sourceRect.width) / 2; - const y1 = sourceVerticalCenter - (info.source.y * sourceRect.height) / 2; + const x1 = (sourceHorizontalCenter + (info.source.x * sourceRect.width) / 2) / transformScale; + const y1 = (sourceVerticalCenter - (info.source.y * sourceRect.height) / 2) / transformScale; - let x2; - let y2; + let x2: number; + let y2: number; + const targetRect = target.div?.getBoundingClientRect(); + if (info.targetName && targetRect) { + const targetHorizontalCenter = targetRect.left - parentRect.left + targetRect.width / 2; + const targetVerticalCenter = targetRect.top - parentRect.top + targetRect.height / 2; - if (info.targetName) { - const targetRect = target.div?.getBoundingClientRect(); - if (targetRect) { - const targetHorizontalCenter = targetRect.left - parentRect.left + targetRect.width / 2; - const targetVerticalCenter = targetRect.top - parentRect.top + targetRect.height / 2; - - x2 = targetHorizontalCenter + (info.target.x * targetRect.width) / 2; - y2 = targetVerticalCenter - (info.target.y * targetRect.height) / 2; - } + x2 = targetHorizontalCenter + (info.target.x * targetRect.width) / 2; + y2 = targetVerticalCenter - (info.target.y * targetRect.height) / 2; } else { const parentHorizontalCenter = parentRect.width / 2; const parentVerticalCenter = parentRect.height / 2; @@ -218,6 +216,8 @@ export const calculateCoordinates = ( x2 = parentHorizontalCenter + (info.target.x * parentRect.width) / 2; y2 = parentVerticalCenter - (info.target.y * parentRect.height) / 2; } + x2 /= transformScale; + y2 /= transformScale; return { x1, y1, x2, y2 }; }; @@ -239,3 +239,26 @@ export const getConnectionStyles = (info: CanvasConnection, scene: Scene, defaul const strokeWidth = info.size ? scene.context.getScale(info.size).get(lastRowIndex) : defaultArrowSize; return { strokeColor, strokeWidth }; }; + +export const getParentBoundingClientRect = (scene: Scene) => { + if (config.featureToggles.canvasPanelPanZoom) { + const transformRef = scene.transformComponentRef?.current; + return transformRef?.instance.contentComponent?.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; +}; diff --git a/yarn.lock b/yarn.lock index 0c6c59dd1ba..f647bf5e591 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17613,6 +17613,7 @@ __metadata: react-virtualized-auto-sizer: "npm:1.0.7" react-window: "npm:1.8.9" react-window-infinite-loader: "npm:1.0.9" + react-zoom-pan-pinch: "npm:^3.3.0" redux: "npm:4.2.1" redux-mock-store: "npm:1.5.4" redux-thunk: "npm:2.4.2" @@ -26217,6 +26218,16 @@ __metadata: languageName: node linkType: hard +"react-zoom-pan-pinch@npm:^3.3.0": + version: 3.3.0 + resolution: "react-zoom-pan-pinch@npm:3.3.0" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 56e102f603dc5d0dbcff2effe403a1f46b718bde0bbad395fc990db0ef48f25fefb6969fec465948e90471a8e8e702cddaaa93742f77075cb1f4f32b10c1ab43 + languageName: node + linkType: hard + "react@npm:18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0"