+ 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"