diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 8188c467ed8..bd32ff33c8b 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -105,6 +105,19 @@ export interface TextDimensionConfig extends BaseDimensionConfig { mode: TextDimensionMode; } +export enum PositionDimensionMode { + Field = 'field', + Fixed = 'fixed', +} + +/** + * Simple position/coordinate dimension - just fixed value or field value, no scaling/clamping + */ +export interface PositionDimensionConfig extends BaseDimensionConfig { + fixed?: number; + mode: PositionDimensionMode; +} + export enum ResourceDimensionMode { Field = 'field', Fixed = 'fixed', diff --git a/packages/grafana-schema/src/common/dimensions.cue b/packages/grafana-schema/src/common/dimensions.cue index 0ffd7d83b48..61de9eb7cf7 100644 --- a/packages/grafana-schema/src/common/dimensions.cue +++ b/packages/grafana-schema/src/common/dimensions.cue @@ -38,6 +38,15 @@ TextDimensionConfig: { fixed?: string }@cuetsy(kind="interface") +PositionDimensionMode: "fixed" | "field" @cuetsy(kind="enum") + +// Simple position/coordinate dimension - just fixed value or field value, no scaling/clamping +PositionDimensionConfig: { + BaseDimensionConfig + mode: PositionDimensionMode + fixed?: number +}@cuetsy(kind="interface") + ResourceDimensionMode: "fixed" | "field" | "mapping" @cuetsy(kind="enum") // Links to a resource (image/svg path) 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 fea3266ca6d..51acba8459b 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 @@ -34,13 +34,13 @@ export interface Constraint { } export interface Placement { - bottom?: number; - height?: number; - left?: number; - right?: number; - rotation?: number; - top?: number; - width?: number; + bottom?: ui.PositionDimensionConfig; + height?: ui.PositionDimensionConfig; + left?: ui.PositionDimensionConfig; + right?: ui.PositionDimensionConfig; + rotation?: ui.ScalarDimensionConfig; + top?: ui.PositionDimensionConfig; + width?: ui.PositionDimensionConfig; } export enum BackgroundImageSize { diff --git a/packages/grafana-schema/src/veneer/common.types.ts b/packages/grafana-schema/src/veneer/common.types.ts index 7a119bdb1af..3eb9c2241b3 100644 --- a/packages/grafana-schema/src/veneer/common.types.ts +++ b/packages/grafana-schema/src/veneer/common.types.ts @@ -29,6 +29,10 @@ export interface ScalarDimensionConfig extends BaseDimensionConfig, Omit export interface TextDimensionConfig extends BaseDimensionConfig, Omit {} +export interface PositionDimensionConfig + extends BaseDimensionConfig, + Omit {} + export interface ColorDimensionConfig extends BaseDimensionConfig, Omit {} export interface ColorDimensionConfig extends BaseDimensionConfig, Omit {} diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index 7e78ad784e7..206263ad286 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -81,6 +81,12 @@ export interface CanvasElementProps { isSelected?: boolean; } +/** Simple numeric size for element defaults - not persisted, just for initial sizing */ +export interface DefaultElementSize { + width?: number; + height?: number; +} + /** * Canvas item builder * @@ -89,7 +95,7 @@ export interface CanvasElementProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface CanvasElementItem extends RegistryItem { /** The default width/height to use when adding */ - defaultSize?: Placement; + defaultSize?: DefaultElementSize; prepareData?: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions) => TData; diff --git a/public/app/features/canvas/elements/button.tsx b/public/app/features/canvas/elements/button.tsx index 749504e80ff..5b3cf6d18d2 100644 --- a/public/app/features/canvas/elements/button.tsx +++ b/public/app/features/canvas/elements/button.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2, PluginState } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { TextDimensionMode } from '@grafana/schema'; +import { ScalarDimensionMode, PositionDimensionMode, TextDimensionMode } from '@grafana/schema'; import { Button, Spinner, useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -122,11 +122,11 @@ export const buttonItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 32, - height: options?.placement?.height ?? 78, - top: options?.placement?.top ?? 100, - left: options?.placement?.left ?? 100, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 32, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 78, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, }), diff --git a/public/app/features/canvas/elements/cloud.tsx b/public/app/features/canvas/elements/cloud.tsx index f215b4dfd9e..6c3ad95f7aa 100644 --- a/public/app/features/canvas/elements/cloud.tsx +++ b/public/app/features/canvas/elements/cloud.tsx @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -94,11 +95,11 @@ export const cloudItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 110, - height: options?.placement?.height ?? 70, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 110, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 70, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/droneFront.tsx b/public/app/features/canvas/elements/droneFront.tsx index f8c7a0ef1bb..7cdc459fde5 100644 --- a/public/app/features/canvas/elements/droneFront.tsx +++ b/public/app/features/canvas/elements/droneFront.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ScalarDimensionConfig } from '@grafana/schema'; +import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions/context'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor'; @@ -89,11 +89,11 @@ export const droneFrontItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 100, - height: options?.placement?.height ?? 26, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 26, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/droneSide.tsx b/public/app/features/canvas/elements/droneSide.tsx index 9ff0a6deabb..0c401ac7adf 100644 --- a/public/app/features/canvas/elements/droneSide.tsx +++ b/public/app/features/canvas/elements/droneSide.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ScalarDimensionConfig } from '@grafana/schema'; +import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions/context'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor'; @@ -88,11 +88,11 @@ export const droneSideItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 100, - height: options?.placement?.height ?? 26, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 26, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/ellipse.tsx b/public/app/features/canvas/elements/ellipse.tsx index d4d7f3fb23f..d5bda2f134a 100644 --- a/public/app/features/canvas/elements/ellipse.tsx +++ b/public/app/features/canvas/elements/ellipse.tsx @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -101,11 +102,11 @@ export const ellipseItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 100, - height: options?.placement?.height ?? 100, - top: options?.placement?.top ?? 100, - left: options?.placement?.left ?? 100, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/metricValue.tsx b/public/app/features/canvas/elements/metricValue.tsx index 5a4f5f697b9..6d86ca1a461 100644 --- a/public/app/features/canvas/elements/metricValue.tsx +++ b/public/app/features/canvas/elements/metricValue.tsx @@ -5,7 +5,7 @@ import { of } from 'rxjs'; import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { TextDimensionMode } from '@grafana/schema'; +import { TextDimensionMode, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { usePanelContext, useStyles2 } from '@grafana/ui'; import { FieldNamePicker, frameHasName, getFrameFieldsDisplayNames } from '@grafana/ui/internal'; import { DimensionContext } from 'app/features/dimensions/context'; @@ -171,9 +171,9 @@ export const metricValueItem: CanvasElementItem = { placement: { width: options?.placement?.width, height: options?.placement?.height, - top: options?.placement?.top ?? 100, - left: options?.placement?.left ?? 100, - rotation: options?.placement?.rotation ?? 0, + top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/parallelogram.tsx b/public/app/features/canvas/elements/parallelogram.tsx index 2dc0df79799..84989a308ff 100644 --- a/public/app/features/canvas/elements/parallelogram.tsx +++ b/public/app/features/canvas/elements/parallelogram.tsx @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -94,11 +95,11 @@ export const parallelogramItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 250, - height: options?.placement?.height ?? 150, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 250, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 150, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/server/server.tsx b/public/app/features/canvas/elements/server/server.tsx index 2ad0e4cd404..c8fc1730a58 100644 --- a/public/app/features/canvas/elements/server/server.tsx +++ b/public/app/features/canvas/elements/server/server.tsx @@ -2,7 +2,12 @@ import { css } from '@emotion/css'; import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema'; +import { + ColorDimensionConfig, + ScalarDimensionConfig, + ScalarDimensionMode, + PositionDimensionMode, +} from '@grafana/schema'; import config from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -76,11 +81,11 @@ export const serverItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 100, - height: options?.placement?.height ?? 100, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, config: { type: ServerType.Single, diff --git a/public/app/features/canvas/elements/text.tsx b/public/app/features/canvas/elements/text.tsx index b5fc96c1aeb..78cfd556ba4 100644 --- a/public/app/features/canvas/elements/text.tsx +++ b/public/app/features/canvas/elements/text.tsx @@ -6,6 +6,7 @@ import { of } from 'rxjs'; import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { Input, usePanelContext, useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -145,11 +146,11 @@ export const textItem: CanvasElementItem = { size: 16, }, placement: { - width: options?.placement?.width ?? 100, - height: options?.placement?.height ?? 100, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/triangle.tsx b/public/app/features/canvas/elements/triangle.tsx index 35b00d05761..d8a32d24641 100644 --- a/public/app/features/canvas/elements/triangle.tsx +++ b/public/app/features/canvas/elements/triangle.tsx @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; @@ -95,11 +96,11 @@ export const triangleItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 160, - height: options?.placement?.height ?? 138, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 160, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 138, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/elements/windTurbine.tsx b/public/app/features/canvas/elements/windTurbine.tsx index 0da6fea9455..d1e841af1c9 100644 --- a/public/app/features/canvas/elements/windTurbine.tsx +++ b/public/app/features/canvas/elements/windTurbine.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ScalarDimensionConfig } from '@grafana/schema'; +import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; import { DimensionContext } from 'app/features/dimensions/context'; import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor'; @@ -85,11 +85,11 @@ export const windTurbineItem: CanvasElementItem = { }, }, placement: { - width: options?.placement?.width ?? 100, - height: options?.placement?.height ?? 155, - top: options?.placement?.top, - left: options?.placement?.left, - rotation: options?.placement?.rotation ?? 0, + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 155, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, }, links: options?.links ?? [], }), diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index f53008f9cb7..7ec4d6b7418 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -14,7 +14,12 @@ import { ActionType, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { TooltipDisplayMode } from '@grafana/schema'; +import { + PositionDimensionConfig, + PositionDimensionMode, + ScalarDimensionMode, + TooltipDisplayMode, +} from '@grafana/schema'; import { ConfirmModal, VariablesInputModal } from '@grafana/ui'; import { LayerElement } from 'app/core/components/Layers/types'; import { config } from 'app/core/config'; @@ -74,6 +79,40 @@ export class ElementState implements LayerElement { showActionVarsModal = false; actionVars: ActionVariableInput = {}; + // Cached values resolved from dimension context + private cachedRotation = 0; + private cachedTop = 0; + private cachedLeft = 0; + private cachedWidth = 100; + private cachedHeight = 100; + private cachedRight?: number; + private cachedBottom?: number; + + /** Check if a position property is field-driven (not fixed) */ + isPositionFieldDriven(prop: 'top' | 'left' | 'width' | 'height' | 'right' | 'bottom'): boolean { + const pos = this.options.placement?.[prop]; + return pos?.mode === PositionDimensionMode.Field && !!pos?.field; + } + + /** Check if rotation is field-driven (has a field binding) */ + isRotationFieldDriven(): boolean { + const rot = this.options.placement?.rotation; + return !!rot?.field; + } + + /** Check if ANY position/size property is field-driven - if so, element can't be moved in editor */ + hasFieldDrivenPosition(): boolean { + return ( + this.isPositionFieldDriven('top') || + this.isPositionFieldDriven('left') || + this.isPositionFieldDriven('width') || + this.isPositionFieldDriven('height') || + this.isPositionFieldDriven('right') || + this.isPositionFieldDriven('bottom') || + this.isRotationFieldDriven() + ); + } + setActionVars = (vars: ActionVariableInput) => { this.actionVars = vars; this.forceUpdate(); @@ -93,7 +132,13 @@ export class ElementState implements LayerElement { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left, }; - options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 }; + options.placement = options.placement ?? { + width: { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: { fixed: 100, mode: PositionDimensionMode.Fixed }, + top: { fixed: 0, mode: PositionDimensionMode.Fixed }, + left: { fixed: 0, mode: PositionDimensionMode.Fixed }, + rotation: { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }, + }; options.background = options.background ?? { color: { fixed: 'transparent' } }; options.border = options.border ?? { color: { fixed: 'dark-green' } }; @@ -121,6 +166,18 @@ export class ElementState implements LayerElement { return this.options.name; } + /** Get the current rotation value (resolved from dimension context) */ + getRotation(): number { + return this.cachedRotation; + } + + /** Set the fixed value of a PositionDimensionConfig */ + private setPositionFixed(pos: PositionDimensionConfig | undefined, value: number): void { + if (pos) { + pos.fixed = value; + } + } + /** Use the configured options to update CSS style properties directly on the wrapper div **/ applyLayoutStylesToDiv(disablePointerEvents?: boolean) { if (config.featureToggles.canvasPanelPanZoom) { @@ -134,7 +191,6 @@ export class ElementState implements LayerElement { const { constraint } = this.options; const { vertical, horizontal } = constraint ?? {}; - const placement: Placement = this.options.placement ?? {}; const editingEnabled = this.getScene()?.isEditingEnabled; @@ -145,95 +201,64 @@ export class ElementState implements LayerElement { // Minimum element size is 10x10 minWidth: '10px', minHeight: '10px', - rotate: `${placement.rotation ?? 0}deg`, + rotate: `${this.cachedRotation}deg`, }; const translate = ['0px', '0px']; switch (vertical) { case VerticalConstraint.Top: - placement.top = placement.top ?? 0; - placement.height = placement.height ?? 100; - style.top = `${placement.top}px`; - style.height = `${placement.height}px`; - delete placement.bottom; + style.top = `${this.cachedTop}px`; + style.height = `${this.cachedHeight}px`; break; case VerticalConstraint.Bottom: - placement.bottom = placement.bottom ?? 0; - placement.height = placement.height ?? 100; - style.bottom = `${placement.bottom}px`; - style.height = `${placement.height}px`; - delete placement.top; + style.bottom = `${this.cachedBottom ?? 0}px`; + style.height = `${this.cachedHeight}px`; break; case VerticalConstraint.TopBottom: - placement.top = placement.top ?? 0; - placement.bottom = placement.bottom ?? 0; - style.top = `${placement.top}px`; - style.bottom = `${placement.bottom}px`; - delete placement.height; + style.top = `${this.cachedTop}px`; + style.bottom = `${this.cachedBottom ?? 0}px`; style.height = ''; break; case VerticalConstraint.Center: - placement.top = placement.top ?? 0; - placement.height = placement.height ?? 100; translate[1] = '-50%'; - style.top = `calc(50% - ${placement.top}px)`; - style.height = `${placement.height}px`; - delete placement.bottom; + style.top = `calc(50% - ${this.cachedTop}px)`; + style.height = `${this.cachedHeight}px`; break; case VerticalConstraint.Scale: - placement.top = placement.top ?? 0; - placement.bottom = placement.bottom ?? 0; - style.top = `${placement.top}%`; - style.bottom = `${placement.bottom}%`; - delete placement.height; + style.top = `${this.cachedTop}%`; + style.bottom = `${this.cachedBottom ?? 0}%`; style.height = ''; break; } switch (horizontal) { case HorizontalConstraint.Left: - placement.left = placement.left ?? 0; - placement.width = placement.width ?? 100; - style.left = `${placement.left}px`; - style.width = `${placement.width}px`; - delete placement.right; + style.left = `${this.cachedLeft}px`; + style.width = `${this.cachedWidth}px`; break; case HorizontalConstraint.Right: - placement.right = placement.right ?? 0; - placement.width = placement.width ?? 100; - style.right = `${placement.right}px`; - style.width = `${placement.width}px`; - delete placement.left; + style.right = `${this.cachedRight ?? 0}px`; + style.width = `${this.cachedWidth}px`; break; case HorizontalConstraint.LeftRight: - placement.left = placement.left ?? 0; - placement.right = placement.right ?? 0; - style.left = `${placement.left}px`; - style.right = `${placement.right}px`; - delete placement.width; + style.left = `${this.cachedLeft}px`; + style.right = `${this.cachedRight ?? 0}px`; style.width = ''; break; case HorizontalConstraint.Center: - placement.left = placement.left ?? 0; - placement.width = placement.width ?? 100; translate[0] = '-50%'; - style.left = `calc(50% - ${placement.left}px)`; - style.width = `${placement.width}px`; - delete placement.right; + style.left = `calc(50% - ${this.cachedLeft}px)`; + style.width = `${this.cachedWidth}px`; break; case HorizontalConstraint.Scale: - placement.left = placement.left ?? 0; - placement.right = placement.right ?? 0; - style.left = `${placement.left}%`; - style.right = `${placement.right}%`; - delete placement.width; + style.left = `${this.cachedLeft}%`; + style.right = `${this.cachedRight ?? 0}%`; style.width = ''; break; } style.transform = `translate(${translate[0]}, ${translate[1]})`; - this.options.placement = placement; this.sizeStyle = style; if (this.div) { @@ -267,7 +292,6 @@ export class ElementState implements LayerElement { const { constraint } = this.options; const { vertical, horizontal } = constraint ?? {}; - const placement: Placement = this.options.placement ?? {}; const editingEnabled = scene?.isEditingEnabled; @@ -275,7 +299,6 @@ export class ElementState implements LayerElement { cursor: editingEnabled ? 'grab' : 'auto', pointerEvents: disablePointerEvents ? 'none' : 'auto', position: 'absolute', - // Minimum element size is 10x10 minWidth: '10px', minHeight: '10px', }; @@ -285,81 +308,50 @@ export class ElementState implements LayerElement { switch (vertical) { case VerticalConstraint.Top: - placement.top = placement.top ?? 0; - placement.height = placement.height ?? 100; - transformY = `${placement.top ?? 0}px`; - style.height = `${placement.height}px`; - delete placement.bottom; + transformY = `${this.cachedTop}px`; + style.height = `${this.cachedHeight}px`; break; case VerticalConstraint.Bottom: - placement.bottom = placement.bottom ?? 0; - placement.height = placement.height ?? 100; - transformY = `${sceneHeight! - (placement.bottom ?? 0) - (placement.height ?? 100)}px`; - style.height = `${placement.height}px`; - delete placement.top; + transformY = `${sceneHeight! - (this.cachedBottom ?? 0) - this.cachedHeight}px`; + style.height = `${this.cachedHeight}px`; break; case VerticalConstraint.TopBottom: - placement.top = placement.top ?? 0; - placement.bottom = placement.bottom ?? 0; - transformY = `${placement.top ?? 0}px`; - style.height = `${sceneHeight! - (placement.top ?? 0) - (placement.bottom ?? 0)}px`; - delete placement.height; + transformY = `${this.cachedTop}px`; + style.height = `${sceneHeight! - this.cachedTop - (this.cachedBottom ?? 0)}px`; break; case VerticalConstraint.Center: - placement.top = placement.top ?? 0; - placement.height = placement.height ?? 100; - transformY = `${sceneHeight! / 2 - (placement.top ?? 0) - (placement.height ?? 0) / 2}px`; - style.height = `${placement.height}px`; - delete placement.bottom; + transformY = `${sceneHeight! / 2 - this.cachedTop - this.cachedHeight / 2}px`; + style.height = `${this.cachedHeight}px`; break; case VerticalConstraint.Scale: - placement.top = placement.top ?? 0; - placement.bottom = placement.bottom ?? 0; - transformY = `${(placement.top ?? 0) * (sceneHeight! / 100)}px`; - style.height = `${sceneHeight! - (placement.top ?? 0) * (sceneHeight! / 100) - (placement.bottom ?? 0) * (sceneHeight! / 100)}px`; - delete placement.height; + transformY = `${this.cachedTop * (sceneHeight! / 100)}px`; + style.height = `${sceneHeight! - this.cachedTop * (sceneHeight! / 100) - (this.cachedBottom ?? 0) * (sceneHeight! / 100)}px`; break; } switch (horizontal) { case HorizontalConstraint.Left: - placement.left = placement.left ?? 0; - placement.width = placement.width ?? 100; - transformX = `${placement.left ?? 0}px`; - style.width = `${placement.width}px`; - delete placement.right; + transformX = `${this.cachedLeft}px`; + style.width = `${this.cachedWidth}px`; break; case HorizontalConstraint.Right: - placement.right = placement.right ?? 0; - placement.width = placement.width ?? 100; - transformX = `${sceneWidth! - (placement.right ?? 0) - (placement.width ?? 100)}px`; - style.width = `${placement.width}px`; - delete placement.left; + transformX = `${sceneWidth! - (this.cachedRight ?? 0) - this.cachedWidth}px`; + style.width = `${this.cachedWidth}px`; break; case HorizontalConstraint.LeftRight: - placement.left = placement.left ?? 0; - placement.right = placement.right ?? 0; - transformX = `${placement.left ?? 0}px`; - style.width = `${sceneWidth! - (placement.left ?? 0) - (placement.right ?? 0)}px`; - delete placement.width; + transformX = `${this.cachedLeft}px`; + style.width = `${sceneWidth! - this.cachedLeft - (this.cachedRight ?? 0)}px`; break; case HorizontalConstraint.Center: - placement.left = placement.left ?? 0; - placement.width = placement.width ?? 100; - transformX = `${sceneWidth! / 2 - (placement.left ?? 0) - (placement.width ?? 0) / 2}px`; - style.width = `${placement.width}px`; - delete placement.right; + transformX = `${sceneWidth! / 2 - this.cachedLeft - this.cachedWidth / 2}px`; + style.width = `${this.cachedWidth}px`; break; case HorizontalConstraint.Scale: - placement.left = placement.left ?? 0; - placement.right = placement.right ?? 0; - transformX = `${(placement.left ?? 0) * (sceneWidth! / 100)}px`; - style.width = `${sceneWidth! - (placement.left ?? 0) * (sceneWidth! / 100) - (placement.right ?? 0) * (sceneWidth! / 100)}px`; - delete placement.width; + transformX = `${this.cachedLeft * (sceneWidth! / 100)}px`; + style.width = `${sceneWidth! - this.cachedLeft * (sceneWidth! / 100) - (this.cachedRight ?? 0) * (sceneWidth! / 100)}px`; break; } - this.options.placement = placement; - style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`; + style.transform = `translate(${transformX}, ${transformY}) rotate(${this.cachedRotation}deg)`; this.sizeStyle = style; if (this.div) { @@ -415,8 +407,8 @@ export class ElementState implements LayerElement { // 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; + if (this.cachedRotation && this.options.placement?.width && this.options.placement?.height) { + const rotationDegrees = this.cachedRotation; const rotationRadians = (Math.PI / 180) * rotationDegrees; let rotationOffset = rotationRadians; @@ -438,8 +430,8 @@ export class ElementState implements LayerElement { 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); + rotationTopOffset = calculateDelta(this.cachedWidth, this.cachedHeight); + rotationLeftOffset = calculateDelta(this.cachedHeight, this.cachedWidth); } const relativeTop = @@ -463,67 +455,103 @@ export class ElementState implements LayerElement { transformScale : 0; - const placement: Placement = {}; + // Don't update placement if any position is field-driven + if (this.hasFieldDrivenPosition()) { + this.applyLayoutStylesToDiv(); + this.revId++; + return; + } const width = (elementContainer?.width ?? 100) / transformScale; const height = (elementContainer?.height ?? 100) / transformScale; + // Helper to create a position dimension config + const fixedPosition = (value: number): PositionDimensionConfig => ({ + fixed: value, + mode: PositionDimensionMode.Fixed, + }); + + const placement: Placement = {}; + switch (vertical) { case VerticalConstraint.Top: - placement.top = relativeTop; - placement.height = height; + placement.top = fixedPosition(relativeTop); + placement.height = fixedPosition(height); + this.cachedTop = relativeTop; + this.cachedHeight = height; break; case VerticalConstraint.Bottom: - placement.bottom = relativeBottom; - placement.height = height; + placement.bottom = fixedPosition(relativeBottom); + placement.height = fixedPosition(height); + this.cachedBottom = relativeBottom; + this.cachedHeight = height; break; case VerticalConstraint.TopBottom: - placement.top = relativeTop; - placement.bottom = relativeBottom; + placement.top = fixedPosition(relativeTop); + placement.bottom = fixedPosition(relativeBottom); + this.cachedTop = relativeTop; + this.cachedBottom = relativeBottom; break; case VerticalConstraint.Center: - const elementCenter = elementContainer ? relativeTop + height / 2 : 0; - const parentCenter = parentContainer ? parentContainer.height / 2 : 0; - const distanceFromCenter = parentCenter - elementCenter; - placement.top = distanceFromCenter; - placement.height = height; + const elementCenterV = elementContainer ? relativeTop + height / 2 : 0; + const parentCenterV = parentContainer ? parentContainer.height / 2 : 0; + const distanceFromCenterV = parentCenterV - elementCenterV; + placement.top = fixedPosition(distanceFromCenterV); + placement.height = fixedPosition(height); + this.cachedTop = distanceFromCenterV; + this.cachedHeight = height; break; case VerticalConstraint.Scale: - placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale; - placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale; + const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale; + const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale; + placement.top = fixedPosition(scaleTop); + placement.bottom = fixedPosition(scaleBottom); + this.cachedTop = scaleTop; + this.cachedBottom = scaleBottom; break; } switch (horizontal) { case HorizontalConstraint.Left: - placement.left = relativeLeft; - placement.width = width; + placement.left = fixedPosition(relativeLeft); + placement.width = fixedPosition(width); + this.cachedLeft = relativeLeft; + this.cachedWidth = width; break; case HorizontalConstraint.Right: - placement.right = relativeRight; - placement.width = width; + placement.right = fixedPosition(relativeRight); + placement.width = fixedPosition(width); + this.cachedRight = relativeRight; + this.cachedWidth = width; break; case HorizontalConstraint.LeftRight: - placement.left = relativeLeft; - placement.right = relativeRight; + placement.left = fixedPosition(relativeLeft); + placement.right = fixedPosition(relativeRight); + this.cachedLeft = relativeLeft; + this.cachedRight = relativeRight; break; case HorizontalConstraint.Center: - const elementCenter = elementContainer ? relativeLeft + width / 2 : 0; - const parentCenter = parentContainer ? parentContainer.width / 2 : 0; - const distanceFromCenter = parentCenter - elementCenter; - placement.left = distanceFromCenter; - placement.width = width; + const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0; + const parentCenterH = parentContainer ? parentContainer.width / 2 : 0; + const distanceFromCenterH = parentCenterH - elementCenterH; + placement.left = fixedPosition(distanceFromCenterH); + placement.width = fixedPosition(width); + this.cachedLeft = distanceFromCenterH; + this.cachedWidth = width; break; case HorizontalConstraint.Scale: - placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale; - placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale; + const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale; + const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale; + placement.left = fixedPosition(scaleLeft); + placement.right = fixedPosition(scaleRight); + this.cachedLeft = scaleLeft; + this.cachedRight = scaleRight; break; } + // Preserve rotation 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; @@ -554,71 +582,109 @@ export class ElementState implements LayerElement { const relativeLeft = Math.round(elementRect.left); const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width); - const placement: Placement = {}; + // Don't update placement if any position is field-driven + if (this.hasFieldDrivenPosition()) { + this.applyLayoutStylesToDiv(); + this.revId++; + return; + } const width = elementRect.width; const height = elementRect.height; - // INFO: calculate it anyway to be able to use it for pan&zoom - placement.top = relativeTop; - placement.left = relativeLeft; + // Helper to create a position dimension config + const fixedPosition = (value: number): PositionDimensionConfig => ({ + fixed: value, + mode: PositionDimensionMode.Fixed, + }); + + const placement: Placement = {}; + + // INFO: calculate for pan&zoom + placement.top = fixedPosition(relativeTop); + placement.left = fixedPosition(relativeLeft); + this.cachedTop = relativeTop; + this.cachedLeft = relativeLeft; switch (vertical) { case VerticalConstraint.Top: - placement.top = relativeTop; - placement.height = height; + placement.top = fixedPosition(relativeTop); + placement.height = fixedPosition(height); + this.cachedTop = relativeTop; + this.cachedHeight = height; break; case VerticalConstraint.Bottom: - placement.bottom = relativeBottom; - placement.height = height; + placement.bottom = fixedPosition(relativeBottom); + placement.height = fixedPosition(height); + this.cachedBottom = relativeBottom; + this.cachedHeight = height; break; case VerticalConstraint.TopBottom: - placement.top = relativeTop; - placement.bottom = relativeBottom; + placement.top = fixedPosition(relativeTop); + placement.bottom = fixedPosition(relativeBottom); + this.cachedTop = relativeTop; + this.cachedBottom = relativeBottom; break; case VerticalConstraint.Center: - const elementCenter = elementContainer ? relativeTop + height / 2 : 0; - const parentCenter = scene.height / 2; // Use scene height instead of scaled viewport height - const distanceFromCenter = parentCenter - elementCenter; - placement.top = distanceFromCenter; - placement.height = height; + const elementCenterV = elementContainer ? relativeTop + height / 2 : 0; + const parentCenterV = scene.height / 2; + const distanceFromCenterV = parentCenterV - elementCenterV; + placement.top = fixedPosition(distanceFromCenterV); + placement.height = fixedPosition(height); + this.cachedTop = distanceFromCenterV; + this.cachedHeight = height; break; case VerticalConstraint.Scale: - placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale; - placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale; + const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale; + const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale; + placement.top = fixedPosition(scaleTop); + placement.bottom = fixedPosition(scaleBottom); + this.cachedTop = scaleTop; + this.cachedBottom = scaleBottom; break; } switch (horizontal) { case HorizontalConstraint.Left: - placement.left = relativeLeft; - placement.width = width; + placement.left = fixedPosition(relativeLeft); + placement.width = fixedPosition(width); + this.cachedLeft = relativeLeft; + this.cachedWidth = width; break; case HorizontalConstraint.Right: - placement.right = relativeRight; - placement.width = width; + placement.right = fixedPosition(relativeRight); + placement.width = fixedPosition(width); + this.cachedRight = relativeRight; + this.cachedWidth = width; break; case HorizontalConstraint.LeftRight: - placement.left = relativeLeft; - placement.right = relativeRight; + placement.left = fixedPosition(relativeLeft); + placement.right = fixedPosition(relativeRight); + this.cachedLeft = relativeLeft; + this.cachedRight = relativeRight; break; case HorizontalConstraint.Center: - const elementCenter = elementContainer ? relativeLeft + width / 2 : 0; - const parentCenter = scene.width / 2; // Use scene width instead of scaled viewport width - const distanceFromCenter = parentCenter - elementCenter; - placement.left = distanceFromCenter; - placement.width = width; + const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0; + const parentCenterH = scene.width / 2; + const distanceFromCenterH = parentCenterH - elementCenterH; + placement.left = fixedPosition(distanceFromCenterH); + placement.width = fixedPosition(width); + this.cachedLeft = distanceFromCenterH; + this.cachedWidth = width; break; case HorizontalConstraint.Scale: - placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale; - placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale; + const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale; + const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale; + placement.left = fixedPosition(scaleLeft); + placement.right = fixedPosition(scaleRight); + this.cachedLeft = scaleLeft; + this.cachedRight = scaleRight; break; } + // Preserve rotation 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; @@ -635,6 +701,32 @@ export class ElementState implements LayerElement { this.revId++; // rerender } + // Update placement values from dimension context + const placement = this.options.placement; + if (placement) { + if (placement.rotation) { + this.cachedRotation = ctx.getScalar(placement.rotation).value(); + } + if (placement.top) { + this.cachedTop = ctx.getPosition(placement.top).value(); + } + if (placement.left) { + this.cachedLeft = ctx.getPosition(placement.left).value(); + } + if (placement.width) { + this.cachedWidth = ctx.getPosition(placement.width).value(); + } + if (placement.height) { + this.cachedHeight = ctx.getPosition(placement.height).value(); + } + if (placement.right) { + this.cachedRight = ctx.getPosition(placement.right).value(); + } + if (placement.bottom) { + this.cachedBottom = ctx.getPosition(placement.bottom).value(); + } + } + const scene = this.getScene(); const frames = scene?.data?.series; @@ -793,6 +885,11 @@ export class ElementState implements LayerElement { }; applyDrag = (event: OnDrag) => { + // Don't allow dragging if any position is field-driven + if (this.hasFieldDrivenPosition()) { + return; + } + const hasHorizontalCenterConstraint = this.options.constraint?.horizontal === HorizontalConstraint.Center; const hasVerticalCenterConstraint = this.options.constraint?.vertical === VerticalConstraint.Center; if (hasHorizontalCenterConstraint || hasVerticalCenterConstraint) { @@ -813,18 +910,31 @@ export class ElementState implements LayerElement { applyRotate = (event: OnRotate) => { const rotationDelta = event.delta; const placement = this.options.placement!; - const placementRotation = placement.rotation ?? 0; + const placementRotation = this.cachedRotation; const calculatedRotation = placementRotation + rotationDelta; // Ensure rotation is between 0 and 360 - placement.rotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360; + const newRotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360; + + // Update the config value as fixed + if (!placement.rotation) { + placement.rotation = { fixed: newRotation, min: 0, max: 360, mode: ScalarDimensionMode.Clamped }; + } else { + placement.rotation.fixed = newRotation; + } + this.cachedRotation = newRotation; 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) => { + // Don't allow resizing if any position is field-driven + if (this.hasFieldDrivenPosition()) { + return; + } + const placement = this.options.placement!; const style = event.target.style; @@ -834,8 +944,8 @@ export class ElementState implements LayerElement { let dirTB = event.direction[1]; // Handle case when element is rotated - if (placement.rotation) { - const rotation = placement.rotation ?? 0; + if (this.cachedRotation) { + const rotation = this.cachedRotation; const rotationInRadians = (rotation * Math.PI) / 180; const originalDirLR = dirLR; const originalDirTB = dirTB; @@ -845,31 +955,37 @@ export class ElementState implements LayerElement { } if (dirLR === 1) { - placement.width = event.width; - style.width = `${placement.width}px`; + this.setPositionFixed(placement.width, event.width); + this.cachedWidth = event.width; + style.width = `${this.cachedWidth}px`; } else if (dirLR === -1) { - placement.left! -= deltaX; - placement.width = event.width; + this.cachedLeft -= deltaX; + this.setPositionFixed(placement.left, this.cachedLeft); + this.cachedWidth = event.width; + this.setPositionFixed(placement.width, this.cachedWidth); if (config.featureToggles.canvasPanelPanZoom) { - style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`; + style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`; } else { - style.left = `${placement.left}px`; + style.left = `${this.cachedLeft}px`; } - style.width = `${placement.width}px`; + style.width = `${this.cachedWidth}px`; } if (dirTB === -1) { - placement.top! -= deltaY; - placement.height = event.height; + this.cachedTop -= deltaY; + this.setPositionFixed(placement.top, this.cachedTop); + this.cachedHeight = event.height; + this.setPositionFixed(placement.height, this.cachedHeight); if (config.featureToggles.canvasPanelPanZoom) { - style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`; + style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`; } else { - style.top = `${placement.top}px`; + style.top = `${this.cachedTop}px`; } - style.height = `${placement.height}px`; + style.height = `${this.cachedHeight}px`; } else if (dirTB === 1) { - placement.height = event.height; - style.height = `${placement.height}px`; + this.cachedHeight = event.height; + this.setPositionFixed(placement.height, this.cachedHeight); + style.height = `${this.cachedHeight}px`; } }; @@ -880,7 +996,8 @@ export class ElementState implements LayerElement { !scene?.isEditingEnabled && (!scene?.tooltipPayload?.isOpen || scene?.tooltipPayload?.element === this); if (shouldHandleTooltip) { this.handleTooltip(event); - } else if (!isSelected) { + } else if (!isSelected && !this.hasFieldDrivenPosition()) { + // Don't show connection anchors for field-driven elements scene?.connections.handleMouseEnter(event); } @@ -1090,6 +1207,25 @@ export class ElementState implements LayerElement { ); }; + // Track if this field-driven element is selected (for showing outline) + isFieldDrivenSelected = false; + + setFieldDrivenSelected(selected: boolean) { + if (this.hasFieldDrivenPosition()) { + this.isFieldDrivenSelected = selected; + // Update the outline style + if (this.div) { + if (selected) { + this.div.style.outline = '2px solid #3274d9'; + this.div.style.outlineOffset = '2px'; + } else { + this.div.style.outline = ''; + this.div.style.outlineOffset = ''; + } + } + } + } + renderElement() { const { item, div } = this; const scene = this.getScene(); @@ -1112,7 +1248,7 @@ export class ElementState implements LayerElement { key={`${this.UID}/${this.revId}`} config={this.options.config} data={this.data} - isSelected={isSelected} + isSelected={isSelected || this.isFieldDrivenSelected} /> {this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())} diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index ad217e45337..228288b9b72 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -9,6 +9,7 @@ import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { ColorDimensionConfig, + PositionDimensionConfig, ResourceDimensionConfig, ScalarDimensionConfig, ScaleDimensionConfig, @@ -21,6 +22,7 @@ import { config } from 'app/core/config'; import { DimensionContext } from 'app/features/dimensions/context'; import { getColorDimensionFromData, + getPositionDimensionFromData, getResourceDimensionFromData, getScalarDimensionFromData, getScaleDimensionFromData, @@ -109,6 +111,22 @@ export class Scene { targetsToSelect = new Set(); + // Track currently selected field-driven element (these aren't in Selecto) + private fieldDrivenSelectedElement?: ElementState; + + clearFieldDrivenSelection = () => { + if (this.fieldDrivenSelectedElement) { + this.fieldDrivenSelectedElement.setFieldDrivenSelected(false); + this.fieldDrivenSelectedElement = undefined; + } + }; + + setFieldDrivenSelection = (element: ElementState) => { + this.clearFieldDrivenSelection(); + this.fieldDrivenSelectedElement = element; + element.setFieldDrivenSelected(true); + }; + constructor( options: Options, public onSave: (cfg: CanvasFrameOptions) => void, @@ -211,6 +229,7 @@ export class Scene { getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color), getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale), getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar), + getPosition: (pos: PositionDimensionConfig) => getPositionDimensionFromData(this.data, pos), getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text), getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res), getDirection: (direction: DirectionDimensionConfig) => getDirectionDimensionFromData(this.data, direction), @@ -267,6 +286,8 @@ export class Scene { clearCurrentSelection(skipNextSelectionBroadcast = false) { this.skipNextSelectionBroadcast = skipNextSelectionBroadcast; + // Clear field-driven selection + this.clearFieldDrivenSelection(); let event: MouseEvent = new MouseEvent('click'); if (config.featureToggles.canvasPanelPanZoom) { this.selecto?.clickTarget(event, this.viewportDiv); @@ -324,6 +345,9 @@ export class Scene { select = (selection: SelectionParams) => { if (this.selecto) { + // Clear any field-driven selection when selecting via Selecto + this.clearFieldDrivenSelection(); + this.selecto.setSelectedTargets(selection.targets); this.updateSelection(selection); this.editModeEnabled.next(false); diff --git a/public/app/features/canvas/runtime/sceneAbleManagement.ts b/public/app/features/canvas/runtime/sceneAbleManagement.ts index 9af3e1a74e0..be4ae63577b 100644 --- a/public/app/features/canvas/runtime/sceneAbleManagement.ts +++ b/public/app/features/canvas/runtime/sceneAbleManagement.ts @@ -69,6 +69,7 @@ const isTargetAlreadySelected = (selectedTarget: HTMLElement, scene: Scene) => { }; // Generate HTML element divs for every canvas element to configure selecto / moveable +// Excludes elements with field-driven positions (they can't be moved in editor) const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => { let targetElements: HTMLDivElement[] = []; @@ -77,7 +78,10 @@ const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] const currentElement = stack.shift(); if (currentElement && currentElement.div) { - targetElements.push(currentElement.div); + // Skip elements with field-driven positions - they can't be moved + if (!currentElement.hasFieldDrivenPosition()) { + targetElements.push(currentElement.div); + } } const nestedElements = currentElement instanceof FrameState ? currentElement.elements : []; diff --git a/public/app/features/dimensions/context.ts b/public/app/features/dimensions/context.ts index ca08090808f..0e09a978215 100644 --- a/public/app/features/dimensions/context.ts +++ b/public/app/features/dimensions/context.ts @@ -1,6 +1,7 @@ import { PanelData } from '@grafana/data'; import { ColorDimensionConfig, + PositionDimensionConfig, ResourceDimensionConfig, ScalarDimensionConfig, ScaleDimensionConfig, @@ -18,6 +19,8 @@ export interface DimensionContext { getScalar(scalar: ScalarDimensionConfig): DimensionSupplier; + getPosition(position: PositionDimensionConfig): DimensionSupplier; + getText(text: TextDimensionConfig): DimensionSupplier; getResource(resource: ResourceDimensionConfig): DimensionSupplier; diff --git a/public/app/features/dimensions/editors/PositionDimensionEditor.tsx b/public/app/features/dimensions/editors/PositionDimensionEditor.tsx new file mode 100644 index 00000000000..9226d30148a --- /dev/null +++ b/public/app/features/dimensions/editors/PositionDimensionEditor.tsx @@ -0,0 +1,131 @@ +import { useCallback, useId, useMemo } from 'react'; + +import { FieldType, SelectableValue, StandardEditorProps } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema'; +import { InlineField, InlineFieldRow, RadioButtonGroup, Select } from '@grafana/ui'; +import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/internal'; +import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; + +import { PositionDimensionOptions } from '../types'; + +type Props = StandardEditorProps; + +export const PositionDimensionEditor = ({ value, context, onChange }: Props) => { + const positionOptions = useMemo( + () => [ + { + label: t('dimensions.position-dimension-editor.label-fixed', 'Fixed'), + value: PositionDimensionMode.Fixed, + description: t('dimensions.position-dimension-editor.description-fixed', 'Fixed value'), + }, + { + label: t('dimensions.position-dimension-editor.label-field', 'Field'), + value: PositionDimensionMode.Field, + description: t('dimensions.position-dimension-editor.description-field', 'Use field value'), + }, + ], + [] + ); + + const fixedValueOption: SelectableValue = useMemo( + () => ({ + label: t('dimensions.position-dimension-editor.fixed-value-option.label', 'Fixed value'), + value: '_____fixed_____', + }), + [] + ); + + const labelWidth = 9; + const fieldName = value?.field; + const names = useFieldDisplayNames(context.data); + // Filter to only show number fields for position values + const selectOptions = useSelectOptions(names, fieldName, fixedValueOption, FieldType.number); + + const onModeChange = useCallback( + (mode: PositionDimensionMode) => { + onChange({ + ...value, + mode, + }); + }, + [onChange, value] + ); + + const onFieldChange = useCallback( + (selection: SelectableValue) => { + const field = selection.value; + if (field && field !== fixedValueOption.value) { + onChange({ + ...value, + field, + }); + } else { + onChange({ + ...value, + field: undefined, + }); + } + }, + [onChange, value, fixedValueOption.value] + ); + + const onFixedChange = useCallback( + (fixed?: number) => { + onChange({ + ...value, + fixed: fixed ?? 0, + }); + }, + [onChange, value] + ); + + const fieldInputId = useId(); + const valueInputId = useId(); + + const mode = value?.mode ?? PositionDimensionMode.Fixed; + const selectedOption = + mode === PositionDimensionMode.Field ? selectOptions.find((v) => v.value === fieldName) : fixedValueOption; + + return ( + <> + + + + + + {mode === PositionDimensionMode.Field && ( + + +