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 3d1dd65d57.

* Set yarn lock to main

* Revert "Set yarn lock to main"

This reverts commit 64bc50557e.

* 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 b026e31d8d.

* 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 <nathanielmarrs@gmail.com>
This commit is contained in:
Drew Slobodnjak 2024-01-02 11:52:21 -08:00 committed by GitHub
parent c598306523
commit 2502fe4d19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 326 additions and 65 deletions

View File

@ -142,6 +142,7 @@ It extends [BaseDimensionConfig](#basedimensionconfig).
| Property | Type | Required | Default | Description | | Property | Type | Required | Default | Description |
|---------------------|-----------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------| |---------------------|-----------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------|
| `inlineEditing` | boolean | **Yes** | `true` | Enable inline editing | | `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<br/>TODO: Figure out how to define a default value for this | | `root` | [object](#root) | **Yes** | | The root element of canvas (frame), where all canvas elements are nested<br/>TODO: Figure out how to define a default value for this |
| `showAdvancedTypes` | boolean | **Yes** | `true` | Show all available element types | | `showAdvancedTypes` | boolean | **Yes** | `true` | Show all available element types |

View File

@ -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 | | `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles |
| `dashboardScene` | Enables dashboard rendering using scenes for all 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 | | `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items | | `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected |

View File

@ -397,6 +397,7 @@
"react-virtualized-auto-sizer": "1.0.7", "react-virtualized-auto-sizer": "1.0.7",
"react-window": "1.8.9", "react-window": "1.8.9",
"react-window-infinite-loader": "1.0.9", "react-window-infinite-loader": "1.0.9",
"react-zoom-pan-pinch": "^3.3.0",
"redux": "4.2.1", "redux": "4.2.1",
"redux-thunk": "2.4.2", "redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.0", "regenerator-runtime": "0.14.0",

View File

@ -158,6 +158,7 @@ export interface FeatureToggles {
panelFilterVariable?: boolean; panelFilterVariable?: boolean;
pdfTables?: boolean; pdfTables?: boolean;
ssoSettingsApi?: boolean; ssoSettingsApi?: boolean;
canvasPanelPanZoom?: boolean;
logsInfiniteScrolling?: boolean; logsInfiniteScrolling?: boolean;
flameGraphItemCollapsing?: boolean; flameGraphItemCollapsing?: boolean;
alertingDetailsViewV2?: boolean; alertingDetailsViewV2?: boolean;

View File

@ -109,6 +109,10 @@ export interface Options {
* Enable inline editing * Enable inline editing
*/ */
inlineEditing: boolean; inlineEditing: boolean;
/**
* Enable pan and zoom
*/
panZoom: boolean;
/** /**
* The root element of canvas (frame), where all canvas elements are nested * The root element of canvas (frame), where all canvas elements are nested
* TODO: Figure out how to define a default value for this * TODO: Figure out how to define a default value for this
@ -135,5 +139,6 @@ export interface Options {
export const defaultOptions: Partial<Options> = { export const defaultOptions: Partial<Options> = {
inlineEditing: true, inlineEditing: true,
panZoom: true,
showAdvancedTypes: true, showAdvancedTypes: true,
}; };

View File

@ -1178,6 +1178,14 @@ var (
Owner: identityAccessTeam, Owner: identityAccessTeam,
Created: time.Date(2023, time.November, 8, 12, 0, 0, 0, time.UTC), 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", Name: "logsInfiniteScrolling",
Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards", Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards",

View File

@ -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 panelFilterVariable,experimental,@grafana/dashboards-squad,2023-11-03,false,false,false,true
pdfTables,privatePreview,@grafana/sharing-squad,2023-11-06,false,false,false,false 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 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 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 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 alertingDetailsViewV2,experimental,@grafana/alerting-squad,2023-11-09,false,false,false,true

1 Name Stage Owner Created requiresDevMode RequiresLicense RequiresRestart FrontendOnly
139 panelFilterVariable experimental @grafana/dashboards-squad 2023-11-03 false false false true
140 pdfTables privatePreview @grafana/sharing-squad 2023-11-06 false false false false
141 ssoSettingsApi experimental @grafana/identity-access-team 2023-11-08 true false false false
142 canvasPanelPanZoom experimental @grafana/dataviz-squad 2023-12-27 false false false true
143 logsInfiniteScrolling experimental @grafana/observability-logs 2023-11-09 false false false true
144 flameGraphItemCollapsing experimental @grafana/observability-traces-and-profiling 2023-11-09 false false false true
145 alertingDetailsViewV2 experimental @grafana/alerting-squad 2023-11-09 false false false true

View File

@ -567,6 +567,10 @@ const (
// Enables the SSO settings API // Enables the SSO settings API
FlagSsoSettingsApi = "ssoSettingsApi" FlagSsoSettingsApi = "ssoSettingsApi"
// FlagCanvasPanelPanZoom
// Allow pan and zoom in canvas panel
FlagCanvasPanelPanZoom = "canvasPanelPanZoom"
// FlagLogsInfiniteScrolling // FlagLogsInfiniteScrolling
// Enables infinite scrolling for the Logs panel in Explore and Dashboards // Enables infinite scrolling for the Logs panel in Explore and Dashboards
FlagLogsInfiniteScrolling = "logsInfiniteScrolling" FlagLogsInfiniteScrolling = "logsInfiniteScrolling"

View File

@ -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 (
<TransformWrapper
doubleClick={{ mode: 'reset' }}
ref={scene.transformComponentRef}
onZoom={onZoom}
onTransformed={(_, state) => {
scene.scale = state.scale;
}}
limitToBounds={true}
disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom}
panning={{ allowLeftClickPan: false }}
>
<TransformComponent>{sceneDiv}</TransformComponent>
</TransformWrapper>
);
};

View File

@ -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 { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {}; const { vertical, horizontal } = constraint ?? {};
@ -214,25 +214,25 @@ export class ElementState implements LayerElement {
const relativeTop = const relativeTop =
elementContainer && parentContainer elementContainer && parentContainer
? Math.round(elementContainer.top - parentContainer.top - parentBorderWidth) ? Math.round(elementContainer.top - parentContainer.top - parentBorderWidth) / transformScale
: 0; : 0;
const relativeBottom = const relativeBottom =
elementContainer && parentContainer elementContainer && parentContainer
? Math.round(parentContainer.bottom - parentBorderWidth - elementContainer.bottom) ? Math.round(parentContainer.bottom - parentBorderWidth - elementContainer.bottom) / transformScale
: 0; : 0;
const relativeLeft = const relativeLeft =
elementContainer && parentContainer elementContainer && parentContainer
? Math.round(elementContainer.left - parentContainer.left - parentBorderWidth) ? Math.round(elementContainer.left - parentContainer.left - parentBorderWidth) / transformScale
: 0; : 0;
const relativeRight = const relativeRight =
elementContainer && parentContainer elementContainer && parentContainer
? Math.round(parentContainer.right - parentBorderWidth - elementContainer.right) ? Math.round(parentContainer.right - parentBorderWidth - elementContainer.right) / transformScale
: 0; : 0;
const placement: Placement = {}; const placement: Placement = {};
const width = elementContainer?.width ?? 100; const width = (elementContainer?.width ?? 100) / transformScale;
const height = elementContainer?.height ?? 100; const height = (elementContainer?.height ?? 100) / transformScale;
switch (vertical) { switch (vertical) {
case VerticalConstraint.Top: case VerticalConstraint.Top:
@ -427,12 +427,12 @@ export class ElementState implements LayerElement {
// kinda like: // kinda like:
// https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44 // 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 placement = this.options.placement!;
const style = event.target.style; const style = event.target.style;
const deltaX = event.delta[0]; const deltaX = event.delta[0] / transformScale;
const deltaY = event.delta[1]; const deltaY = event.delta[1] / transformScale;
const dirLR = event.direction[0]; const dirLR = event.direction[0];
const dirTB = event.direction[1]; const dirTB = event.direction[1];

View File

@ -1,6 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import Moveable from 'moveable'; 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 { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import Selecto from 'selecto'; 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 { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors';
import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections'; import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections';
import { AnchorPoint, CanvasTooltipPayload, LayerActionID } from 'app/plugins/panel/canvas/types'; 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 appEvents from '../../../core/app_events';
import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel'; import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
import { HorizontalConstraint, Placement, VerticalConstraint } from '../types'; import { HorizontalConstraint, Placement, VerticalConstraint } from '../types';
import { SceneTransformWrapper } from './SceneTransformWrapper';
import { constraintViewable, dimensionViewable, settingsViewable } from './ables'; import { constraintViewable, dimensionViewable, settingsViewable } from './ables';
import { ElementState } from './element'; import { ElementState } from './element';
import { FrameState } from './frame'; import { FrameState } from './frame';
@ -57,6 +60,7 @@ export class Scene {
width = 0; width = 0;
height = 0; height = 0;
scale = 1;
style: CSSProperties = {}; style: CSSProperties = {};
data?: PanelData; data?: PanelData;
selecto?: Selecto; selecto?: Selecto;
@ -66,9 +70,22 @@ export class Scene {
currentLayer?: FrameState; currentLayer?: FrameState;
isEditingEnabled?: boolean; isEditingEnabled?: boolean;
shouldShowAdvancedTypes?: boolean; shouldShowAdvancedTypes?: boolean;
shouldPanZoom?: boolean;
skipNextSelectionBroadcast = false; skipNextSelectionBroadcast = false;
ignoreDataUpdate = false; ignoreDataUpdate = false;
panel: CanvasPanel; 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; isPanelEditing = locationService.getSearchObject().editPanel !== undefined;
@ -84,15 +101,17 @@ export class Scene {
subscription: Subscription; subscription: Subscription;
targetsToSelect = new Set<HTMLDivElement>(); targetsToSelect = new Set<HTMLDivElement>();
transformComponentRef: RefObject<ReactZoomPanPinchContentRef> | undefined;
constructor( constructor(
cfg: CanvasFrameOptions, cfg: CanvasFrameOptions,
enableEditing: boolean, enableEditing: boolean,
showAdvancedTypes: boolean, showAdvancedTypes: boolean,
panZoom: boolean,
public onSave: (cfg: CanvasFrameOptions) => void, public onSave: (cfg: CanvasFrameOptions) => void,
panel: CanvasPanel panel: CanvasPanel
) { ) {
this.root = this.load(cfg, enableEditing, showAdvancedTypes); this.root = this.load(cfg, enableEditing, showAdvancedTypes, panZoom);
this.subscription = this.editModeEnabled.subscribe((open) => { this.subscription = this.editModeEnabled.subscribe((open) => {
if (!this.moveable || !this.isEditingEnabled) { if (!this.moveable || !this.isEditingEnabled) {
@ -103,6 +122,7 @@ export class Scene {
this.panel = panel; this.panel = panel;
this.connections = new Connections(this); this.connections = new Connections(this);
this.transformComponentRef = createRef();
} }
getNextElementName = (isFrame = false) => { getNextElementName = (isFrame = false) => {
@ -124,7 +144,7 @@ export class Scene {
return !this.byName.has(v); 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( this.root = new RootElement(
cfg ?? { cfg ?? {
type: 'frame', type: 'frame',
@ -136,6 +156,7 @@ export class Scene {
this.isEditingEnabled = enableEditing; this.isEditingEnabled = enableEditing;
this.shouldShowAdvancedTypes = showAdvancedTypes; this.shouldShowAdvancedTypes = showAdvancedTypes;
this.shouldPanZoom = panZoom;
setTimeout(() => { setTimeout(() => {
if (this.div) { if (this.div) {
@ -369,7 +390,7 @@ export class Scene {
this.selecto = new Selecto({ this.selecto = new Selecto({
container: this.div, container: this.div,
rootContainer: this.div, rootContainer: getParent(this),
selectableTargets: targetElements, selectableTargets: targetElements,
toggleContinueSelect: 'shift', toggleContinueSelect: 'shift',
selectFromInside: false, selectFromInside: false,
@ -439,7 +460,9 @@ export class Scene {
e.events.forEach((event) => { e.events.forEach((event) => {
const targetedElement = this.findElementByTarget(event.target); const targetedElement = this.findElementByTarget(event.target);
if (targetedElement) { if (targetedElement) {
targetedElement.setPlacementFromConstraint(); if (targetedElement) {
targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale);
}
} }
}); });
@ -449,7 +472,7 @@ export class Scene {
.on('dragEnd', (event) => { .on('dragEnd', (event) => {
const targetedElement = this.findElementByTarget(event.target); const targetedElement = this.findElementByTarget(event.target);
if (targetedElement) { if (targetedElement) {
targetedElement.setPlacementFromConstraint(); targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale);
} }
this.moved.next(Date.now()); this.moved.next(Date.now());
@ -465,13 +488,13 @@ export class Scene {
vertical: VerticalConstraint.Top, vertical: VerticalConstraint.Top,
horizontal: HorizontalConstraint.Left, horizontal: HorizontalConstraint.Left,
}; };
targetedElement.setPlacementFromConstraint(); targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale);
} }
}) })
.on('resize', (event) => { .on('resize', (event) => {
const targetedElement = this.findElementByTarget(event.target); const targetedElement = this.findElementByTarget(event.target);
if (targetedElement) { if (targetedElement) {
targetedElement.applyResize(event); targetedElement.applyResize(event, this.scale);
if (this.connections.connectionsNeedUpdate(targetedElement) && this.moveableActionCallback) { if (this.connections.connectionsNeedUpdate(targetedElement) && this.moveableActionCallback) {
this.moveableActionCallback(true); this.moveableActionCallback(true);
@ -507,7 +530,7 @@ export class Scene {
targetedElement.tempConstraint = undefined; 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 isTooltipValid = (this.tooltip?.element?.data?.links?.length ?? 0) > 0;
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid; const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid;
return ( const sceneDiv = (
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}> // TODO: Address this eslint error
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
key={this.revId}
className={this.styles.wrap}
style={this.style}
ref={this.setRef}
onMouseDown={(e) => {
// 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.connections.render()}
{this.root.render()} {this.root.render()}
{canShowContextMenu && ( {canShowContextMenu && (
<Portal> <Portal>
<CanvasContextMenu scene={this} panel={this.panel} /> <CanvasContextMenu
scene={this}
panel={this.panel}
onVisibilityChange={this.contextMenuOnVisibilityChange}
/>
</Portal> </Portal>
)} )}
{canShowElementTooltip && ( {canShowElementTooltip && (
@ -662,6 +708,12 @@ export class Scene {
)} )}
</div> </div>
); );
return config.featureToggles.canvasPanelPanZoom ? (
<SceneTransformWrapper scene={this}>{sceneDiv}</SceneTransformWrapper>
) : (
sceneDiv
);
} }
} }

View File

@ -67,6 +67,7 @@ export class CanvasPanel extends Component<Props, State> {
this.props.options.root, this.props.options.root,
this.props.options.inlineEditing, this.props.options.inlineEditing,
this.props.options.showAdvancedTypes, this.props.options.showAdvancedTypes,
this.props.options.panZoom,
this.onUpdateScene, this.onUpdateScene,
this this
); );
@ -227,14 +228,20 @@ export class CanvasPanel extends Component<Props, State> {
const inlineEditingSwitched = this.props.options.inlineEditing !== nextProps.options.inlineEditing; const inlineEditingSwitched = this.props.options.inlineEditing !== nextProps.options.inlineEditing;
const shouldShowAdvancedTypesSwitched = const shouldShowAdvancedTypesSwitched =
this.props.options.showAdvancedTypes !== nextProps.options.showAdvancedTypes; 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) { if (inlineEditingSwitched) {
// Replace scene div to prevent selecto instance leaks // Replace scene div to prevent selecto instance leaks
this.scene.revId++; this.scene.revId++;
} }
this.needsReload = false; 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.updateSize(nextProps.width, nextProps.height);
this.scene.updateData(nextProps.data); this.scene.updateData(nextProps.data);
changed = true; changed = true;

View File

@ -15,9 +15,10 @@ import { getElementTypes, onAddItem } from '../utils';
type Props = { type Props = {
scene: Scene; scene: Scene;
panel: CanvasPanel; 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 inlineEditorOpen = panel.state.openInlineEdit;
const [isMenuVisible, setIsMenuVisible] = useState<boolean>(false); const [isMenuVisible, setIsMenuVisible] = useState<boolean>(false);
const [anchorPoint, setAnchorPoint] = useState<AnchorPoint>({ x: 0, y: 0 }); const [anchorPoint, setAnchorPoint] = useState<AnchorPoint>({ x: 0, y: 0 });
@ -29,7 +30,7 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => {
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(event: Event) => { (event: Event) => {
if (!(event instanceof MouseEvent)) { if (!(event instanceof MouseEvent) || event.ctrlKey) {
return; return;
} }
@ -45,8 +46,9 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => {
} }
setAnchorPoint({ x: event.pageX, y: event.pageY }); setAnchorPoint({ x: event.pageX, y: event.pageY });
setIsMenuVisible(true); setIsMenuVisible(true);
onVisibilityChange(true);
}, },
[scene, panel] [scene, panel, onVisibilityChange]
); );
useEffect(() => { useEffect(() => {
@ -65,6 +67,7 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => {
const closeContextMenu = () => { const closeContextMenu = () => {
setIsMenuVisible(false); setIsMenuVisible(false);
onVisibilityChange(false);
}; };
const renderMenuItems = () => { const renderMenuItems = () => {
@ -114,9 +117,10 @@ export const CanvasContextMenu = ({ scene, panel }: Props) => {
let offsetY = anchorPoint.y; let offsetY = anchorPoint.y;
let offsetX = anchorPoint.x; let offsetX = anchorPoint.x;
if (scene.div) { if (scene.div) {
const transformScale = scene.scale;
const sceneContainerDimensions = scene.div.getBoundingClientRect(); const sceneContainerDimensions = scene.div.getBoundingClientRect();
offsetY = offsetY - sceneContainerDimensions.top; offsetY = (offsetY - sceneContainerDimensions.top) / transformScale;
offsetX = offsetX - sceneContainerDimensions.left; offsetX = (offsetX - sceneContainerDimensions.left) / transformScale;
} }
onAddItem(option, rootLayer, { onAddItem(option, rootLayer, {

View File

@ -7,7 +7,7 @@ import { config } from 'app/core/config';
import { Scene } from 'app/features/canvas/runtime/scene'; import { Scene } from 'app/features/canvas/runtime/scene';
import { ConnectionState } from '../../types'; import { ConnectionState } from '../../types';
import { calculateCoordinates, getConnectionStyles } from '../../utils'; import { calculateCoordinates, getConnectionStyles, getParentBoundingClientRect } from '../../utils';
type Props = { type Props = {
setSVGRef: (anchorElement: SVGSVGElement) => void; setSVGRef: (anchorElement: SVGSVGElement) => void;
@ -104,13 +104,14 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
const { source, target, info } = v; const { source, target, info } = v;
const sourceRect = source.div?.getBoundingClientRect(); const sourceRect = source.div?.getBoundingClientRect();
const parent = source.div?.parentElement; const parent = source.div?.parentElement;
const parentRect = parent?.getBoundingClientRect(); const transformScale = scene.scale;
const parentRect = getParentBoundingClientRect(scene);
if (!sourceRect || !parent || !parentRect) { if (!sourceRect || !parent || !parentRect) {
return; 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); const { strokeColor, strokeWidth } = getConnectionStyles(info, scene, defaultArrowSize);

View File

@ -7,7 +7,7 @@ import { ElementState } from 'app/features/canvas/runtime/element';
import { Scene } from 'app/features/canvas/runtime/scene'; import { Scene } from 'app/features/canvas/runtime/scene';
import { ConnectionState } from '../../types'; 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 { CONNECTION_ANCHOR_ALT, ConnectionAnchors, CONNECTION_ANCHOR_HIGHLIGHT_OFFSET } from './ConnectionAnchors';
import { ConnectionSVG } from './ConnectionSVG'; import { ConnectionSVG } from './ConnectionSVG';
@ -103,7 +103,8 @@ export class Connections {
} }
const elementBoundingRect = element.div!.getBoundingClientRect(); 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 relativeTop = elementBoundingRect.top - (parentBoundingRect?.top ?? 0);
const relativeLeft = elementBoundingRect.left - (parentBoundingRect?.left ?? 0); const relativeLeft = elementBoundingRect.left - (parentBoundingRect?.left ?? 0);
@ -111,10 +112,10 @@ export class Connections {
if (this.connectionAnchorDiv) { if (this.connectionAnchorDiv) {
this.connectionAnchorDiv.style.display = 'none'; this.connectionAnchorDiv.style.display = 'none';
this.connectionAnchorDiv.style.display = 'block'; this.connectionAnchorDiv.style.display = 'block';
this.connectionAnchorDiv.style.top = `${relativeTop}px`; this.connectionAnchorDiv.style.top = `${relativeTop / transformScale}px`;
this.connectionAnchorDiv.style.left = `${relativeLeft}px`; this.connectionAnchorDiv.style.left = `${relativeLeft / transformScale}px`;
this.connectionAnchorDiv.style.height = `${elementBoundingRect.height}px`; this.connectionAnchorDiv.style.height = `${elementBoundingRect.height / transformScale}px`;
this.connectionAnchorDiv.style.width = `${elementBoundingRect.width}px`; this.connectionAnchorDiv.style.width = `${elementBoundingRect.width / transformScale}px`;
} }
}; };
@ -140,12 +141,18 @@ export class Connections {
return; return;
} }
const parentBoundingRect = this.scene.div.parentElement.getBoundingClientRect(); const transformScale = this.scene.scale;
const x = event.pageX - parentBoundingRect.x; const parentBoundingRect = getParentBoundingClientRect(this.scene);
const y = event.pageY - parentBoundingRect.y;
this.connectionLine.setAttribute('x2', `${x}`); if (!parentBoundingRect) {
this.connectionLine.setAttribute('y2', `${y}`); 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 connectionLineX1 = this.connectionLine.x1.baseVal.value;
const connectionLineY1 = this.connectionLine.y1.baseVal.value; const connectionLineY1 = this.connectionLine.y1.baseVal.value;
@ -161,15 +168,21 @@ export class Connections {
if (!event.buttons) { if (!event.buttons) {
if (this.connectionSource && this.connectionSource.div && this.connectionSource.div.parentElement) { if (this.connectionSource && this.connectionSource.div && this.connectionSource.div.parentElement) {
const sourceRect = this.connectionSource.div.getBoundingClientRect(); const sourceRect = this.connectionSource.div.getBoundingClientRect();
const parentRect = this.connectionSource.div.parentElement.getBoundingClientRect();
const sourceVerticalCenter = sourceRect.top - parentRect.top + sourceRect.height / 2; const transformScale = this.scene.scale;
const sourceHorizontalCenter = sourceRect.left - parentRect.left + sourceRect.width / 2; 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 // Convert from DOM coords to connection coords
// TODO: Break this out into util function and add tests // TODO: Break this out into util function and add tests
const sourceX = (connectionLineX1 - sourceHorizontalCenter) / (sourceRect.width / 2); const sourceX = (connectionLineX1 - sourceHorizontalCenter) / (sourceRect.width / 2 / transformScale);
const sourceY = (sourceVerticalCenter - connectionLineY1) / (sourceRect.height / 2); const sourceY = (sourceVerticalCenter - connectionLineY1) / (sourceRect.height / 2 / transformScale);
let targetX; let targetX;
let targetY; let targetY;
@ -242,10 +255,20 @@ export class Connections {
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
if (this.connectionSVG && this.connectionLine && this.scene.div && this.scene.div.parentElement) { if (this.connectionSVG && this.connectionLine && this.scene.div && this.scene.div.parentElement) {
const connectionStartTargetBox = selectedTarget.getBoundingClientRect(); const connectionStartTargetBox = selectedTarget.getBoundingClientRect();
const parentBoundingRect = this.scene.div.parentElement.getBoundingClientRect();
const x = connectionStartTargetBox.x - parentBoundingRect.x + CONNECTION_ANCHOR_HIGHLIGHT_OFFSET; const transformScale = this.scene.scale;
const y = connectionStartTargetBox.y - parentBoundingRect.y + CONNECTION_ANCHOR_HIGHLIGHT_OFFSET; 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 mouseX = clientX - parentBoundingRect.x;
const mouseY = clientY - parentBoundingRect.y; const mouseY = clientY - parentBoundingRect.y;

View File

@ -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<string, unknown, unknown, unknown>) => {
const styles = useStyles2(getStyles);
return (
<>
<HorizontalGroup className={styles.hGroup}>
<Alert
title="Pan and zoom controls"
severity="info"
buttonContent={<Icon name="question-circle" size="xl" />}
className={styles.alert}
onRemove={() => {
const newWindow = window.open(helpUrl, '_blank', 'noopener,noreferrer');
if (newWindow) {
newWindow.opener = null;
}
}}
>
<VerticalGroup>
<ul>
<li>
Pan:
<ul>
<li>Middle mouse</li>
<li>CTRL + right mouse</li>
</ul>
</li>
<li>Zoom: Scroll wheel</li>
<li>Reset: Double click</li>
</ul>
</VerticalGroup>
</Alert>
</HorizontalGroup>
</>
);
};
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%' },
}),
});

View File

@ -1,10 +1,12 @@
import { FieldConfigProperty, PanelOptionsEditorBuilder, PanelPlugin } from '@grafana/data'; import { FieldConfigProperty, PanelOptionsEditorBuilder, PanelPlugin } from '@grafana/data';
import { config } from '@grafana/runtime';
import { FrameState } from 'app/features/canvas/runtime/frame'; import { FrameState } from 'app/features/canvas/runtime/frame';
import { CanvasPanel, InstanceState } from './CanvasPanel'; import { CanvasPanel, InstanceState } from './CanvasPanel';
import { getConnectionEditor } from './editor/connectionEditor'; import { getConnectionEditor } from './editor/connectionEditor';
import { getElementEditor } from './editor/element/elementEditor'; import { getElementEditor } from './editor/element/elementEditor';
import { getLayerEditor } from './editor/layer/layerEditor'; import { getLayerEditor } from './editor/layer/layerEditor';
import { PanZoomHelp } from './editor/panZoomHelp';
import { canvasMigrationHandler } from './migrations'; import { canvasMigrationHandler } from './migrations';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
@ -22,6 +24,21 @@ export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilde
description: 'Enable selection of experimental element types', description: 'Enable selection of experimental element types',
defaultValue: true, 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<Options>(CanvasPanel) export const plugin = new PanelPlugin<Options>(CanvasPanel)

View File

@ -88,6 +88,8 @@ composableKinds: PanelCfg: {
inlineEditing: bool | *true inlineEditing: bool | *true
// Show all available element types // Show all available element types
showAdvancedTypes: bool | *true showAdvancedTypes: bool | *true
// Enable pan and zoom
panZoom: bool | *true
// The root element of canvas (frame), where all canvas elements are nested // The root element of canvas (frame), where all canvas elements are nested
// TODO: Figure out how to define a default value for this // TODO: Figure out how to define a default value for this
root: { root: {

View File

@ -106,6 +106,10 @@ export interface Options {
* Enable inline editing * Enable inline editing
*/ */
inlineEditing: boolean; inlineEditing: boolean;
/**
* Enable pan and zoom
*/
panZoom: boolean;
/** /**
* The root element of canvas (frame), where all canvas elements are nested * The root element of canvas (frame), where all canvas elements are nested
* TODO: Figure out how to define a default value for this * TODO: Figure out how to define a default value for this
@ -132,5 +136,6 @@ export interface Options {
export const defaultOptions: Partial<Options> = { export const defaultOptions: Partial<Options> = {
inlineEditing: true, inlineEditing: true,
panZoom: true,
showAdvancedTypes: true, showAdvancedTypes: true,
}; };

View File

@ -190,27 +190,25 @@ export const calculateCoordinates = (
sourceRect: DOMRect, sourceRect: DOMRect,
parentRect: DOMRect, parentRect: DOMRect,
info: CanvasConnection, info: CanvasConnection,
target: ElementState target: ElementState,
transformScale: number
) => { ) => {
const sourceHorizontalCenter = sourceRect.left - parentRect.left + sourceRect.width / 2; const sourceHorizontalCenter = sourceRect.left - parentRect.left + sourceRect.width / 2;
const sourceVerticalCenter = sourceRect.top - parentRect.top + sourceRect.height / 2; const sourceVerticalCenter = sourceRect.top - parentRect.top + sourceRect.height / 2;
// Convert from connection coords to DOM coords // Convert from connection coords to DOM coords
const x1 = sourceHorizontalCenter + (info.source.x * sourceRect.width) / 2; const x1 = (sourceHorizontalCenter + (info.source.x * sourceRect.width) / 2) / transformScale;
const y1 = sourceVerticalCenter - (info.source.y * sourceRect.height) / 2; const y1 = (sourceVerticalCenter - (info.source.y * sourceRect.height) / 2) / transformScale;
let x2; let x2: number;
let y2; 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) { x2 = targetHorizontalCenter + (info.target.x * targetRect.width) / 2;
const targetRect = target.div?.getBoundingClientRect(); y2 = targetVerticalCenter - (info.target.y * targetRect.height) / 2;
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;
}
} else { } else {
const parentHorizontalCenter = parentRect.width / 2; const parentHorizontalCenter = parentRect.width / 2;
const parentVerticalCenter = parentRect.height / 2; const parentVerticalCenter = parentRect.height / 2;
@ -218,6 +216,8 @@ export const calculateCoordinates = (
x2 = parentHorizontalCenter + (info.target.x * parentRect.width) / 2; x2 = parentHorizontalCenter + (info.target.x * parentRect.width) / 2;
y2 = parentVerticalCenter - (info.target.y * parentRect.height) / 2; y2 = parentVerticalCenter - (info.target.y * parentRect.height) / 2;
} }
x2 /= transformScale;
y2 /= transformScale;
return { x1, y1, x2, y2 }; 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; const strokeWidth = info.size ? scene.context.getScale(info.size).get(lastRowIndex) : defaultArrowSize;
return { strokeColor, strokeWidth }; 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;
};

View File

@ -17613,6 +17613,7 @@ __metadata:
react-virtualized-auto-sizer: "npm:1.0.7" react-virtualized-auto-sizer: "npm:1.0.7"
react-window: "npm:1.8.9" react-window: "npm:1.8.9"
react-window-infinite-loader: "npm:1.0.9" react-window-infinite-loader: "npm:1.0.9"
react-zoom-pan-pinch: "npm:^3.3.0"
redux: "npm:4.2.1" redux: "npm:4.2.1"
redux-mock-store: "npm:1.5.4" redux-mock-store: "npm:1.5.4"
redux-thunk: "npm:2.4.2" redux-thunk: "npm:2.4.2"
@ -26217,6 +26218,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react@npm:18.2.0":
version: 18.2.0 version: 18.2.0
resolution: "react@npm:18.2.0" resolution: "react@npm:18.2.0"