diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md index cab358dbf69..162639163a9 100644 --- a/docs/sources/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md @@ -107,6 +107,16 @@ When right clicking an element, you are able to edit, delete, duplicate, and mod {{< figure src="/static/img/docs/canvas-panel/canvas-context-menu-9-2-0.png" max-width="750px" caption="Canvas element context menu" >}} +### Element snapping and alignment + +When you're moving elements around the canvas, snapping and alignment guides help you create more precise layouts. + +{{% admonition type="note" %}} +Currently, element snapping and alignment only works when the canvas is not zoomed in. +{{% /admonition %}} + + + ## Canvas options ### Inline editing diff --git a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx index 005f23bc4ac..c868cd3d497 100644 --- a/public/app/features/canvas/runtime/SceneTransformWrapper.tsx +++ b/public/app/features/canvas/runtime/SceneTransformWrapper.tsx @@ -13,10 +13,37 @@ type SceneTransformWrapperProps = { export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransformWrapperProps) => { const onZoom = (zoomPanPinchRef: ReactZoomPanPinchRef) => { const scale = zoomPanPinchRef.state.scale; + scene.scale = scale; + }; + + const onZoomStop = (zoomPanPinchRef: ReactZoomPanPinchRef) => { + const scale = zoomPanPinchRef.state.scale; + scene.scale = scale; + updateMoveable(scale); + }; + + const onTransformed = ( + _: ReactZoomPanPinchRef, + state: { + scale: number; + positionX: number; + positionY: number; + } + ) => { + const scale = state.scale; + scene.scale = scale; + updateMoveable(scale); + }; + + const updateMoveable = (scale: number) => { if (scene.moveable && scale > 0) { scene.moveable.zoom = 1 / scale; + if (scale === 1) { + scene.moveable.snappable = true; + } else { + scene.moveable.snappable = false; + } } - scene.scale = scale; }; const onSceneContainerMouseDown = (e: React.MouseEvent) => { @@ -38,9 +65,8 @@ export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransf doubleClick={{ mode: 'reset' }} ref={scene.transformComponentRef} onZoom={onZoom} - onTransformed={(_, state) => { - scene.scale = state.scale; - }} + onZoomStop={onZoomStop} + onTransformed={onTransformed} limitToBounds={true} disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom} panning={{ allowLeftClickPan: false }} diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 4031523ece0..e753b732e31 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -397,9 +397,19 @@ export class Scene { hitRate: 0, }); + const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; + const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; + this.moveable = new Moveable(this.div!, { draggable: allowChanges && !this.editModeEnabled.getValue(), resizable: allowChanges, + + // Setup snappable + snappable: allowChanges, + snapDirections: snapDirections, + elementSnapDirections: elementSnapDirections, + elementGuidelines: targetElements, + ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)], props: { dimensionViewable: allowChanges, @@ -426,9 +436,27 @@ export class Scene { .on('dragStart', (event) => { this.ignoreDataUpdate = true; this.setNonTargetPointerEvents(event.target, true); + + // Remove the selected element from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } }) - .on('dragGroupStart', (event) => { + .on('dragGroupStart', (e) => { this.ignoreDataUpdate = true; + + // Remove the selected elements from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + for (let event of e.events) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + } }) .on('drag', (event) => { const targetedElement = this.findElementByTarget(event.target); @@ -463,6 +491,11 @@ export class Scene { if (targetedElement) { targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } + + // re-add the selected elements to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + this.moveable.elementGuidelines.push(event.target); + } } }); @@ -478,11 +511,24 @@ export class Scene { this.moved.next(Date.now()); this.ignoreDataUpdate = false; this.setNonTargetPointerEvents(event.target, false); + + // re-add the selected element to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + this.moveable.elementGuidelines.push(event.target); + } }) .on('resizeStart', (event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { + // Remove the selected element from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + targetedElement.tempConstraint = { ...targetedElement.options.constraint }; targetedElement.options.constraint = { vertical: VerticalConstraint.Top, @@ -491,6 +537,17 @@ export class Scene { targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); } }) + .on('resizeGroupStart', (e) => { + // Remove the selected elements from the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + for (let event of e.events) { + const targetIndex = this.moveable.elementGuidelines.indexOf(event.target); + if (targetIndex > -1) { + this.moveable.elementGuidelines.splice(targetIndex, 1); + } + } + } + }) .on('resize', (event) => { const targetedElement = this.findElementByTarget(event.target); if (targetedElement) { @@ -531,6 +588,19 @@ export class Scene { } targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale); + + // re-add the selected element to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + this.moveable.elementGuidelines.push(event.target); + } + } + }) + .on('resizeGroupEnd', (e) => { + // re-add the selected elements to the snappable guidelines + if (this.moveable && this.moveable.elementGuidelines) { + for (let event of e.events) { + this.moveable.elementGuidelines.push(event.target); + } } });