mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 23:03:01 +08:00
Canvas: Add ability to rotate elements (#83295)
This commit is contained in:
@ -38,6 +38,7 @@ export interface Placement {
|
||||
height?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
rotation?: number;
|
||||
top?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -13,7 +13,7 @@ import { ConstraintSelectionBox } from './ConstraintSelectionBox';
|
||||
import { QuickPositioning } from './QuickPositioning';
|
||||
import { CanvasEditorOptions } from './elementEditor';
|
||||
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height'];
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height', 'rotation'];
|
||||
|
||||
const horizontalOptions: Array<SelectableValue<HorizontalConstraint>> = [
|
||||
{ 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 (
|
||||
<InlineFieldRow key={p}>
|
||||
<InlineField label={p} labelWidth={8} grow={true}>
|
||||
<NumberInput value={v} onChange={(v) => onPositionChange(v, p)} />
|
||||
<NumberInput min={min} max={max} value={v} onChange={(v) => onPositionChange(v, p)} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
|
@ -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")
|
||||
|
@ -36,6 +36,7 @@ export interface Placement {
|
||||
height?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
rotation?: number;
|
||||
top?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
Reference in New Issue
Block a user