Canvas: Add ability to rotate elements (#83295)

This commit is contained in:
Nathan Marrs
2024-03-22 14:00:50 -06:00
committed by GitHub
parent ae59ab2f5b
commit 566cee7d6b
6 changed files with 118 additions and 12 deletions

View File

@ -38,6 +38,7 @@ export interface Placement {
height?: number;
left?: number;
right?: number;
rotation?: number;
top?: number;
width?: number;
}

View File

@ -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;

View File

@ -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;

View File

@ -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>
);

View File

@ -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")

View File

@ -36,6 +36,7 @@ export interface Placement {
height?: number;
left?: number;
right?: number;
rotation?: number;
top?: number;
width?: number;
}