Canvas: Field driven layout

This commit is contained in:
Drew Slobodnjak
2025-12-02 23:33:19 -08:00
parent e36ea78771
commit 308bc56d0f
31 changed files with 880 additions and 311 deletions
+13
View File
@@ -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',
@@ -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)
@@ -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 {
@@ -29,6 +29,10 @@ export interface ScalarDimensionConfig extends BaseDimensionConfig<number>, Omit
export interface TextDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.TextDimensionConfig, 'fixed'> {}
export interface PositionDimensionConfig
extends BaseDimensionConfig<number>,
Omit<raw.PositionDimensionConfig, 'fixed'> {}
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
+7 -1
View File
@@ -81,6 +81,12 @@ export interface CanvasElementProps<TConfig = unknown, TData = unknown> {
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<TConfig = unknown, TData = unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryItem {
/** The default width/height to use when adding */
defaultSize?: Placement;
defaultSize?: DefaultElementSize;
prepareData?: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TConfig>) => TData;
@@ -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<ButtonConfig, ButtonData> = {
},
},
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 },
},
}),
@@ -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 ?? [],
}),
@@ -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 ?? [],
}),
@@ -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 ?? [],
}),
@@ -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<CanvasElementConfig, CanvasElementDa
},
},
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 ?? [],
}),
+12 -6
View File
@@ -4,7 +4,13 @@ import { CSSProperties } from 'react';
import { LinkModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
ResourceDimensionMode,
ScalarDimensionMode,
PositionDimensionMode,
} from '@grafana/schema';
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -76,11 +82,11 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
},
},
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 ?? [],
}),
@@ -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<TextConfig, TextData> = {
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 ?? [],
}),
@@ -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 ?? [],
}),
@@ -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<ServerConfig, ServerData> = {
},
},
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,
+6 -5
View File
@@ -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<TextConfig, TextData> = {
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 ?? [],
}),
@@ -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 ?? [],
}),
@@ -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 ?? [],
}),
+330 -194
View File
@@ -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}
/>
</div>
{this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
@@ -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<HTMLDivElement>();
// 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);
@@ -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 : [];
@@ -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<number>;
getPosition(position: PositionDimensionConfig): DimensionSupplier<number>;
getText(text: TextDimensionConfig): DimensionSupplier<string>;
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
@@ -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<PositionDimensionConfig, PositionDimensionOptions>;
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<string> = 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<string>) => {
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 (
<>
<InlineFieldRow>
<InlineField
label={t('dimensions.position-dimension-editor.label-source', 'Source')}
labelWidth={labelWidth}
grow={true}
>
<RadioButtonGroup value={mode} options={positionOptions} onChange={onModeChange} fullWidth />
</InlineField>
</InlineFieldRow>
{mode === PositionDimensionMode.Field && (
<InlineFieldRow>
<InlineField
label={t('dimensions.position-dimension-editor.label-field', 'Field')}
labelWidth={labelWidth}
grow={true}
>
<Select
inputId={fieldInputId}
value={selectedOption}
options={selectOptions}
onChange={onFieldChange}
noOptionsMessage={t('dimensions.position-dimension-editor.no-fields', 'No number fields found')}
/>
</InlineField>
</InlineFieldRow>
)}
{mode === PositionDimensionMode.Fixed && (
<InlineFieldRow>
<InlineField
label={t('dimensions.position-dimension-editor.label-value', 'Value')}
labelWidth={labelWidth}
grow={true}
>
<NumberInput id={valueInputId} value={value?.fixed ?? 0} onChange={onFixedChange} />
</InlineField>
</InlineFieldRow>
)}
</>
);
};
@@ -0,0 +1,60 @@
import { DataFrame, Field } from '@grafana/data';
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { DimensionSupplier } from './types';
import { findField, getLastNotNullFieldValue } from './utils';
//---------------------------------------------------------
// Position dimension - simple fixed or field value
//---------------------------------------------------------
export function getPositionDimension(
frame: DataFrame | undefined,
config: PositionDimensionConfig
): DimensionSupplier<number> {
return getPositionDimensionForField(findField(frame, config?.field), config);
}
export function getPositionDimensionForField(
field: Field | undefined,
config: PositionDimensionConfig
): DimensionSupplier<number> {
const v = config.fixed ?? 0;
const mode = config.mode ?? PositionDimensionMode.Fixed;
if (mode === PositionDimensionMode.Fixed) {
return {
isAssumed: !config.fixed,
fixed: v,
value: () => v,
get: () => v,
};
}
// Field mode
if (!field) {
return {
isAssumed: true,
fixed: v,
value: () => v,
get: () => v,
};
}
const get = (i: number) => {
const val = field.values[i];
if (val === null || typeof val !== 'number') {
return 0;
}
return val;
};
return {
field,
get,
value: () => {
const val = getLastNotNullFieldValue(field);
return typeof val === 'number' ? val : 0;
},
};
}
+4
View File
@@ -46,6 +46,10 @@ export interface TextDimensionOptions {
// anything?
}
export interface PositionDimensionOptions {
// anything?
}
export const defaultTextConfig: TextDimensionConfig = Object.freeze({
fixed: '',
mode: TextDimensionMode.Field,
+17
View File
@@ -6,12 +6,14 @@ import {
TextDimensionConfig,
ColorDimensionConfig,
ScalarDimensionConfig,
PositionDimensionConfig,
DirectionDimensionConfig,
ConnectionDirection,
} from '@grafana/schema';
import { getColorDimension } from './color';
import { getDirectionDimension } from './direction';
import { getPositionDimension } from './position';
import { getResourceDimension } from './resource';
import { getScalarDimension } from './scalar';
import { getScaledDimension } from './scale';
@@ -78,6 +80,21 @@ export function getScalarDimensionFromData(
return getScalarDimension(undefined, cfg);
}
export function getPositionDimensionFromData(
data: PanelData | undefined,
cfg: PositionDimensionConfig
): DimensionSupplier<number> {
if (data?.series && cfg.field) {
for (const frame of data.series) {
const d = getPositionDimension(frame, cfg);
if (!d.isAssumed || data.series.length === 1) {
return d;
}
}
}
return getPositionDimension(undefined, cfg);
}
export function getResourceDimensionFromData(
data: PanelData | undefined,
cfg: ResourceDimensionConfig
@@ -1,10 +1,12 @@
import { useObservable } from 'react-use';
import { Subject } from 'rxjs';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { SelectableValue, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { PositionDimensionConfig, ScalarDimensionConfig, ScalarDimensionMode } from '@grafana/schema';
import { Field, Icon, InlineField, InlineFieldRow, Select, Stack } from '@grafana/ui';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
import { PositionDimensionEditor } from 'app/features/dimensions/editors/PositionDimensionEditor';
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
import { HorizontalConstraint, Options, Placement, VerticalConstraint } from '../../panelcfg.gen';
@@ -12,7 +14,7 @@ import { ConstraintSelectionBox } from './ConstraintSelectionBox';
import { QuickPositioning } from './QuickPositioning';
import { CanvasEditorOptions } from './elementEditor';
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height', 'rotation'];
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height'];
type Props = StandardEditorProps<unknown, CanvasEditorOptions, Options>;
@@ -61,8 +63,9 @@ export function PlacementEditor({ item }: Props) {
const { options } = element;
const { placement, constraint: layout } = options;
if (placement) {
placement.rotation = placement?.rotation ?? 0;
// Initialize rotation if not set
if (placement && !placement.rotation) {
placement.rotation = { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
}
const reselectElementAfterChange = () => {
@@ -95,20 +98,34 @@ export function PlacementEditor({ item }: Props) {
reselectElementAfterChange();
};
const onPositionChange = (value: number | undefined, placement: keyof Placement) => {
element.options.placement![placement] = value ?? element.options.placement![placement];
element.applyLayoutStylesToDiv();
settings.scene.clearCurrentSelection(true);
reselectElementAfterChange();
const onPositionChange = (value: PositionDimensionConfig | undefined, key: keyof Placement) => {
if (value && key !== 'rotation') {
element.options.placement![key] = value as any;
element.updateData(settings.scene.context);
element.applyLayoutStylesToDiv();
settings.scene.clearCurrentSelection(true);
reselectElementAfterChange();
}
};
const onRotationChange = (value?: ScalarDimensionConfig) => {
if (value) {
element.options.placement!.rotation = value;
element.updateData(settings.scene.context);
element.applyLayoutStylesToDiv();
settings.scene.clearCurrentSelection(true);
reselectElementAfterChange();
}
};
const constraint = element.tempConstraint ?? layout ?? {};
const editorContext = { ...settings.scene.context, data: settings.scene.context.getPanelData()?.series ?? [] };
return (
<div>
<QuickPositioning onPositionChange={onPositionChange} settings={settings} element={element} />
<br />
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')}>
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')} noMargin>
<Stack direction="row">
<ConstraintSelectionBox
onVerticalConstraintChange={onVerticalConstraintChange}
@@ -134,7 +151,7 @@ export function PlacementEditor({ item }: Props) {
<br />
<Field label={t('canvas.placement-editor.label-position', 'Position')}>
<Field label={t('canvas.placement-editor.label-position', 'Position')} noMargin>
<>
{places.map((p) => {
const v = placement![p];
@@ -142,18 +159,40 @@ export function PlacementEditor({ item }: Props) {
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 min={min} max={max} value={v} onChange={(v) => onPositionChange(v, p)} />
<PositionDimensionEditor
value={v as PositionDimensionConfig}
context={editorContext}
onChange={(val) => onPositionChange(val, p)}
item={{} as any}
/>
</InlineField>
</InlineFieldRow>
);
})}
{placement?.rotation && (
<InlineFieldRow>
<InlineField label={t('canvas.placement-editor.label-rotation', 'rotation')} labelWidth={8} grow={true}>
<ScalarDimensionEditor
value={placement.rotation}
context={editorContext}
onChange={onRotationChange}
item={
{
id: 'rotation',
name: 'Rotation',
settings: {
min: 0,
max: 360,
},
} as StandardEditorsRegistryItem<ScalarDimensionConfig>
}
/>
</InlineField>
</InlineFieldRow>
)}
</>
</Field>
</div>
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { IconButton, useStyles2 } from '@grafana/ui';
import { ElementState } from 'app/features/canvas/runtime/element';
import { QuickPlacement } from 'app/features/canvas/types';
@@ -11,7 +12,7 @@ import { HorizontalConstraint, VerticalConstraint, Placement } from '../../panel
import { CanvasEditorOptions } from './elementEditor';
type Props = {
onPositionChange: (value: number | undefined, placement: keyof Placement) => void;
onPositionChange: (value: PositionDimensionConfig | undefined, placement: keyof Placement) => void;
element: ElementState;
settings: CanvasEditorOptions;
};
@@ -19,6 +20,17 @@ type Props = {
export const QuickPositioning = ({ onPositionChange, element, settings }: Props) => {
const styles = useStyles2(getStyles);
// Helper to get numeric value from PositionDimensionConfig
const getPositionValue = (config: PositionDimensionConfig | undefined): number => {
return config?.fixed ?? 0;
};
// Helper to create a fixed PositionDimensionConfig
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
const onQuickPositioningChange = (position: QuickPlacement) => {
const defaultConstraint = { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left };
const originalConstraint = { ...element.options.constraint };
@@ -26,24 +38,27 @@ export const QuickPositioning = ({ onPositionChange, element, settings }: Props)
element.options.constraint = defaultConstraint;
element.setPlacementFromConstraint();
const height = getPositionValue(element.options.placement?.height);
const width = getPositionValue(element.options.placement?.width);
switch (position) {
case QuickPlacement.Top:
onPositionChange(0, 'top');
onPositionChange(fixedPosition(0), 'top');
break;
case QuickPlacement.Bottom:
onPositionChange(getRightBottomPosition(element.options.placement?.height ?? 0, 'bottom'), 'top');
onPositionChange(fixedPosition(getRightBottomPosition(height, 'bottom')), 'top');
break;
case QuickPlacement.VerticalCenter:
onPositionChange(getCenterPosition(element.options.placement?.height ?? 0, 'v'), 'top');
onPositionChange(fixedPosition(getCenterPosition(height, 'v')), 'top');
break;
case QuickPlacement.Left:
onPositionChange(0, 'left');
onPositionChange(fixedPosition(0), 'left');
break;
case QuickPlacement.Right:
onPositionChange(getRightBottomPosition(element.options.placement?.width ?? 0, 'right'), 'left');
onPositionChange(fixedPosition(getRightBottomPosition(width, 'right')), 'left');
break;
case QuickPlacement.HorizontalCenter:
onPositionChange(getCenterPosition(element.options.placement?.width ?? 0, 'h'), 'left');
onPositionChange(fixedPosition(getCenterPosition(width, 'h')), 'left');
break;
}
@@ -1,7 +1,19 @@
import { PanelModel, OneClickMode } from '@grafana/data';
import { PositionDimensionMode, ScalarDimensionMode } from '@grafana/schema';
import { Options } from './panelcfg.gen';
// Helper to migrate a position value from number to PositionDimensionConfig
const migratePositionValue = (value: number | undefined) => {
if (value === undefined) {
return undefined;
}
return {
fixed: value,
mode: PositionDimensionMode.Fixed,
};
};
export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
const pluginVersion = panel?.pluginVersion ?? '';
@@ -99,5 +111,52 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
}
}
// migrate placement values from numbers to dimension configs
if (parseFloat(pluginVersion) <= 12.4) {
const root = panel.options?.root;
if (root?.elements) {
for (const element of root.elements) {
if (element.placement) {
// Migrate rotation from number to ScalarDimensionConfig
if (typeof element.placement.rotation === 'number') {
element.placement.rotation = {
fixed: element.placement.rotation,
min: 0,
max: 360,
mode: ScalarDimensionMode.Clamped,
};
} else if (!element.placement.rotation) {
element.placement.rotation = {
fixed: 0,
min: 0,
max: 360,
mode: ScalarDimensionMode.Clamped,
};
}
// Migrate position values from numbers to PositionDimensionConfig
if (typeof element.placement.top === 'number') {
element.placement.top = migratePositionValue(element.placement.top);
}
if (typeof element.placement.left === 'number') {
element.placement.left = migratePositionValue(element.placement.left);
}
if (typeof element.placement.width === 'number') {
element.placement.width = migratePositionValue(element.placement.width);
}
if (typeof element.placement.height === 'number') {
element.placement.height = migratePositionValue(element.placement.height);
}
if (typeof element.placement.right === 'number') {
element.placement.right = migratePositionValue(element.placement.right);
}
if (typeof element.placement.bottom === 'number') {
element.placement.bottom = migratePositionValue(element.placement.bottom);
}
}
}
}
}
return panel.options;
};
+9 -9
View File
@@ -34,17 +34,17 @@ composableKinds: PanelCfg: {
vertical?: VerticalConstraint
} @cuetsy(kind="interface")
Placement: {
top?: float64
left?: float64
right?: float64
bottom?: float64
Placement: {
top?: ui.PositionDimensionConfig
left?: ui.PositionDimensionConfig
right?: ui.PositionDimensionConfig
bottom?: ui.PositionDimensionConfig
width?: float64
height?: float64
width?: ui.PositionDimensionConfig
height?: ui.PositionDimensionConfig
rotation?: float64
} @cuetsy(kind="interface")
rotation?: ui.ScalarDimensionConfig
} @cuetsy(kind="interface")
BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile")
BackgroundConfig: {
+7 -7
View File
@@ -32,13 +32,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 {
+32 -3
View File
@@ -1,7 +1,7 @@
import { isNumber, isString } from 'lodash';
import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data';
import { ConnectionDirection } from '@grafana/schema';
import { ConnectionDirection, PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
import { hasAlphaPanels, config } from 'app/core/config';
import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element';
@@ -15,6 +15,9 @@ import { AnchorPoint, ConnectionState, LineStyle, StrokeDasharray } from './type
export function doSelect(scene: Scene, element: ElementState | FrameState) {
try {
// Clear any previous field-driven selection
scene.clearFieldDrivenSelection?.();
let selection: SelectionParams = { targets: [] };
if (element instanceof FrameState) {
const targetElements: HTMLDivElement[] = [];
@@ -22,6 +25,14 @@ export function doSelect(scene: Scene, element: ElementState | FrameState) {
selection.targets = targetElements;
selection.frame = element;
scene.select(selection);
} else if (element.hasFieldDrivenPosition()) {
// Field-driven elements can't be selected via Selecto, show custom selection
scene.currentLayer = element.parent;
scene.setFieldDrivenSelection(element);
// Clear Selecto selection and broadcast this element as selected
scene.selecto?.setSelectedTargets([]);
scene.moveable!.target = [];
scene.selection.next([element]);
} else {
scene.currentLayer = element.parent;
selection.targets = [element?.div!];
@@ -81,12 +92,30 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState |
name: '',
};
// Helper to create a fixed PositionDimensionConfig
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
if (anchorPoint) {
newElementOptions.placement = { ...newElementOptions.placement, top: anchorPoint.y, left: anchorPoint.x };
newElementOptions.placement = {
...newElementOptions.placement,
top: fixedPosition(anchorPoint.y),
left: fixedPosition(anchorPoint.x),
};
}
if (newItem.defaultSize) {
newElementOptions.placement = { ...newElementOptions.placement, ...newItem.defaultSize };
// defaultSize uses simple numbers, convert to PositionDimensionConfig
const sizeConfig: Partial<typeof newElementOptions.placement> = {};
if (newItem.defaultSize.width !== undefined) {
sizeConfig.width = fixedPosition(newItem.defaultSize.width);
}
if (newItem.defaultSize.height !== undefined) {
sizeConfig.height = fixedPosition(newItem.defaultSize.height);
}
newElementOptions.placement = { ...newElementOptions.placement, ...sizeConfig };
}
if (rootLayer) {