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 79d92cd2b59..f8badb02569 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 @@ -38,6 +38,7 @@ export interface Placement { height?: number; left?: number; right?: number; + rotation?: number; top?: number; width?: number; } diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 0643f3af78f..91f0fed919b 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -1,5 +1,5 @@ import React, { CSSProperties } from 'react'; -import { OnDrag, OnResize } from 'react-moveable/declaration/types'; +import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types'; import { LayerElement } from 'app/core/components/Layers/types'; import { @@ -52,7 +52,7 @@ export class ElementState implements LayerElement { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left, }; - options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0 }; + options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 }; options.background = options.background ?? { color: { fixed: 'transparent' } }; options.border = options.border ?? { color: { fixed: 'dark-green' } }; const scene = this.getScene(); @@ -99,6 +99,7 @@ export class ElementState implements LayerElement { // Minimum element size is 10x10 minWidth: '10px', minHeight: '10px', + rotate: `${placement.rotation ?? 0}deg`, }; const translate = ['0px', '0px']; @@ -219,21 +220,56 @@ export class ElementState implements LayerElement { : parseFloat(getComputedStyle(this.div?.parentElement!).borderWidth); } + // For elements with rotation, a delta needs to be applied to account for bounding box rotation + // TODO: Fix behavior for top+bottom, left+right, center, and scale constraints + let rotationTopOffset = 0; + let rotationLeftOffset = 0; + if (this.options.placement?.rotation && this.options.placement?.width && this.options.placement?.height) { + const rotationDegrees = this.options.placement.rotation; + const rotationRadians = (Math.PI / 180) * rotationDegrees; + let rotationOffset = rotationRadians; + + switch (true) { + case rotationDegrees >= 0 && rotationDegrees < 90: + // no-op + break; + case rotationDegrees >= 90 && rotationDegrees < 180: + rotationOffset = Math.PI - rotationRadians; + break; + case rotationDegrees >= 180 && rotationDegrees < 270: + rotationOffset = Math.PI + rotationRadians; + break; + case rotationDegrees >= 270: + rotationOffset = -rotationRadians; + break; + } + + const calculateDelta = (dimension1: number, dimension2: number) => + (dimension1 / 2) * Math.sin(rotationOffset) + (dimension2 / 2) * (Math.cos(rotationOffset) - 1); + + rotationTopOffset = calculateDelta(this.options.placement.width, this.options.placement.height); + rotationLeftOffset = calculateDelta(this.options.placement.height, this.options.placement.width); + } + const relativeTop = elementContainer && parentContainer - ? Math.round(elementContainer.top - parentContainer.top - parentBorderWidth) / transformScale + ? Math.round(elementContainer.top - parentContainer.top - parentBorderWidth + rotationTopOffset) / + transformScale : 0; const relativeBottom = elementContainer && parentContainer - ? Math.round(parentContainer.bottom - parentBorderWidth - elementContainer.bottom) / transformScale + ? Math.round(parentContainer.bottom - parentBorderWidth - elementContainer.bottom + rotationTopOffset) / + transformScale : 0; const relativeLeft = elementContainer && parentContainer - ? Math.round(elementContainer.left - parentContainer.left - parentBorderWidth) / transformScale + ? Math.round(elementContainer.left - parentContainer.left - parentBorderWidth + rotationLeftOffset) / + transformScale : 0; const relativeRight = elementContainer && parentContainer - ? Math.round(parentContainer.right - parentBorderWidth - elementContainer.right) / transformScale + ? Math.round(parentContainer.right - parentBorderWidth - elementContainer.right + rotationLeftOffset) / + transformScale : 0; const placement: Placement = {}; @@ -293,6 +329,12 @@ export class ElementState implements LayerElement { break; } + if (this.options.placement?.rotation) { + placement.rotation = this.options.placement.rotation; + placement.width = this.options.placement.width; + placement.height = this.options.placement.height; + } + this.options.placement = placement; this.applyLayoutStylesToDiv(); @@ -436,16 +478,36 @@ export class ElementState implements LayerElement { event.target.style.transform = event.transform; }; + applyRotate = (event: OnRotate) => { + const absoluteRotationDegree = event.absoluteRotation; + + const placement = this.options.placement!; + // Ensure rotation is between 0 and 360 + placement.rotation = absoluteRotationDegree - Math.floor(absoluteRotationDegree / 360) * 360; + event.target.style.transform = event.transform; + }; + // kinda like: // https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44 applyResize = (event: OnResize, transformScale = 1) => { const placement = this.options.placement!; const style = event.target.style; - const deltaX = event.delta[0] / transformScale; - const deltaY = event.delta[1] / transformScale; - const dirLR = event.direction[0]; - const dirTB = event.direction[1]; + let deltaX = event.delta[0] / transformScale; + let deltaY = event.delta[1] / transformScale; + let dirLR = event.direction[0]; + let dirTB = event.direction[1]; + + // Handle case when element is rotated + if (placement.rotation) { + const rotation = placement.rotation ?? 0; + const rotationInRadians = (rotation * Math.PI) / 180; + const originalDirLR = dirLR; + const originalDirTB = dirTB; + + dirLR = Math.sign(originalDirLR * Math.cos(rotationInRadians) - originalDirTB * Math.sin(rotationInRadians)); + dirTB = Math.sign(originalDirLR * Math.sin(rotationInRadians) + originalDirTB * Math.cos(rotationInRadians)); + } if (dirLR === 1) { placement.width = event.width; diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index db8a1d05c0c..83aede44d51 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -385,6 +385,22 @@ export class Scene { return targetElements; }; + disableCustomables = () => { + this.moveable!.props = { + dimensionViewable: false, + constraintViewable: false, + settingsViewable: false, + }; + }; + + enableCustomables = () => { + this.moveable!.props = { + dimensionViewable: true, + constraintViewable: true, + settingsViewable: true, + }; + }; + initMoveable = (destroySelecto = false, allowChanges = true) => { const targetElements = this.generateTargetElements(this.root.elements); @@ -408,6 +424,10 @@ export class Scene { draggable: allowChanges && !this.editModeEnabled.getValue(), resizable: allowChanges, + // Setup rotatable + rotatable: allowChanges, + throttleRotate: 5, + // Setup snappable snappable: allowChanges, snapDirections: snapDirections, @@ -423,6 +443,21 @@ export class Scene { origin: false, className: this.styles.selected, }) + .on('rotateStart', () => { + this.disableCustomables(); + }) + .on('rotate', (event) => { + const targetedElement = this.findElementByTarget(event.target); + + if (targetedElement) { + targetedElement.applyRotate(event); + } + }) + .on('rotateEnd', () => { + this.enableCustomables(); + // Update the editor with the new rotation + this.moved.next(Date.now()); + }) .on('click', (event) => { const targetedElement = this.findElementByTarget(event.target); let elementSupportsEditing = false; diff --git a/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx b/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx index 016193e3892..d2316b4a31b 100644 --- a/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/element/PlacementEditor.tsx @@ -13,7 +13,7 @@ import { ConstraintSelectionBox } from './ConstraintSelectionBox'; import { QuickPositioning } from './QuickPositioning'; import { CanvasEditorOptions } from './elementEditor'; -const places: Array = ['top', 'left', 'bottom', 'right', 'width', 'height']; +const places: Array = ['top', 'left', 'bottom', 'right', 'width', 'height', 'rotation']; const horizontalOptions: Array> = [ { label: 'Left', value: HorizontalConstraint.Left }, @@ -126,10 +126,15 @@ export function PlacementEditor({ item }: Props) { if (v == null) { return null; } + + // Need to set explicit min/max for rotation as logic only can handle 0-360 + const min = p === 'rotation' ? 0 : undefined; + const max = p === 'rotation' ? 360 : undefined; + return ( - onPositionChange(v, p)} /> + onPositionChange(v, p)} /> ); diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index dad78bd4fa3..5c2941e2c1c 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -42,6 +42,8 @@ composableKinds: PanelCfg: { width?: float64 height?: float64 + + rotation?: float64 } @cuetsy(kind="interface") BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile") diff --git a/public/app/plugins/panel/canvas/panelcfg.gen.ts b/public/app/plugins/panel/canvas/panelcfg.gen.ts index a37e7107777..6efd9bedc8b 100644 --- a/public/app/plugins/panel/canvas/panelcfg.gen.ts +++ b/public/app/plugins/panel/canvas/panelcfg.gen.ts @@ -36,6 +36,7 @@ export interface Placement { height?: number; left?: number; right?: number; + rotation?: number; top?: number; width?: number; }