Canvas: Pan and zoom improvement (#86879)
* fix(canvas): style linter issue
* feat(canvas): update infinite viewer root element
* fix(canvas): resize element on moving
* fix(canvas): global element position
* fix(canvas): connection anchor position
* fix(canvas): connection position
* fix(canvas): connection coordinates calculation
* cleaning
* fix(canvas): calculation connection coordinates
* fix(canvas): initial connections svg position
* fix(canvas): align connection with new coordinates system
* fix(canvas): temporary element position fix
* comment
* Canvas: Fix canvas selecto (#104621)
* fix(canvas): get context back
* fix(canvas): selecto containers
* clean up
* Canvas: Fix jumpy elements behaviour on select and drag (#104641)
fix(canvas): jumpy elements behaviour on select and drag
* fix(canvas): connection regression (#104682)
* Canvas pan + zoom: Updated mouse interactions (#104648)
* feat: canvas panel pan + zoom key binds
* feat: panning when right click + ctrl
* chore: some cleanup
* chore: adjust mouse move delta by canvas scale
* Canvas: Zoom to content (#104950)
feat(canvas): zoom to content
* Canvas pan + zoom: Unique key for canvas panel elements (#104947)
fix: unique keys
* Canvas: Placement adjustment (#105117)
* feat(canvas): top/left placement migration
* feat(canvas): adjust constraint system for pan and zoom usage
* feat(canvas): support other constraints
* Canvas: Align connection anchors and element rotation (#106002)
* feat(canvas): align connection anchors and element rotation
* clean up
* clean-up math
* Canvas: Disable pan when pan+zoom toggle is false (#106224)
chore: no pan when pan+zoom toggle is disabled
* Canvas: Fix desync between scene and InfiniteViewer zoom/position on element addition (#106655)
feat(canvas): keep pan and zoom state to re-apply
* Canvas: Clicking on metricValue dropdown does not work; right-click triggers context menu instead (#106701)
fix(canvas): metricValue left click dropdown
* Canvas: Double-click on canvas should reset scale and position (#106709)
fix(canvas): dblclick to reset zoom
* Canvas: Put new canvas pan and zoom under feature toggle (#107001)
* feat(canvas): put pan and zoom under FF
* clean-up
* clean-up
* fix(canvas): clear selection on scene manipulation
* fix(canvas): any types
* Canvas: Fix canvas apply styles (#107404)
* fix(canvas): apply styles
* remove deps
* Canvas: E2E test coverage (#107474)
* betterer
* feat(canvas): sceneAbleManagement tests
* feat(canvas): playwright tests
* chore(canvas): add draft e2e tests
* chore(canvas): clean draft e2e tests
* chore(canvas): fix draft e2e tests
* chore(canvas): todo comments
* chore(canvas): update e2e tests
* chore(canvas): delete scene unit tests
* chore(canvas): delete sceneAbleManagement unit tests
* chore(canvas): delete sceneAbleManagement unit tests
* chore(canvas): linter
* chore(canvas): locales
* chore(canvas): remove flag checking
* Fix canvas connection point z-indexing bug (#107223)
fix(canvas): hoist connect points one level higher in DOM to fix z-index
* Revert "Fix canvas connection point z-indexing bug" (#108146)
Revert "Fix canvas connection point z-indexing bug (#107223)"
This reverts commit e419cb164a.
* Canvas: Set canvas zoom range (#108318)
* chore(canvas): set zoom range
* chore(canvas): naming
* Canvas: Fix infinite-viewer connections viewport values (#108315)
chore(canvas): infinite-viewer connections viewport values
* Canvas: Fix z-indices order and explicit pointer events (#108284)
* fix(canvas-connection-points): z-indices order, explicit pointer events
* chore(canvas): remove user select
* chore(canvas): naming
* chore(canvas): create ConnectionAnchor2 component
---------
Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com>
* Canvas: Hide anchors on panel resize (#108588)
chore(canvas): hide anchors on panel resize
* Canvas: Center & scale select, resize, zoom bugs (#108749)
* Canvas: Fix center and scale constraint with zoom
* Remove unused resize flag
* Consolidate and clean up
* fix(canvas): re-size scale and center elements
---------
Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com>
* Canvas: Fix on constraint change during zoom (#108947)
* Canvas: Ensure correct panel size during edit (#108953)
* chore(canvas): re-gen cue
---------
Co-authored-by: drew08t <drew08@gmail.com>
Co-authored-by: Sven Grossmann <svennergr@gmail.com>
Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
Co-authored-by: Jesse David Peterson <jesdavpet@users.noreply.github.com>
Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com>
This commit is contained in:
co-authored by
drew08t
Sven Grossmann
Alex Spencer
Jesse David Peterson
Drew Slobodnjak
parent
400aeccb35
commit
66b01e49a1
@@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{}
|
||||
|
||||
// Copy methods for all subresource types
|
||||
|
||||
|
||||
|
||||
// DeepCopy creates a full deep copy of DashboardStatus
|
||||
func (s *DashboardStatus) DeepCopy() *DashboardStatus {
|
||||
cpy := &DashboardStatus{}
|
||||
|
||||
@@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{}
|
||||
|
||||
// Copy methods for all subresource types
|
||||
|
||||
|
||||
|
||||
// DeepCopy creates a full deep copy of DashboardStatus
|
||||
func (s *DashboardStatus) DeepCopy() *DashboardStatus {
|
||||
cpy := &DashboardStatus{}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Locator } from '@playwright/test';
|
||||
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
canvasPanelPanZoom: true,
|
||||
},
|
||||
});
|
||||
|
||||
test.describe('Canvas Panel - Scene Tests', () => {
|
||||
test.beforeEach(async ({ page, gotoDashboardPage }) => {
|
||||
const dashboardPage = await gotoDashboardPage({});
|
||||
const panelEditPage = await dashboardPage.addPanel();
|
||||
await panelEditPage.setVisualization('Canvas');
|
||||
|
||||
// Wait for canvas panel to load
|
||||
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should create and render canvas panel with scene elements', async ({ page }) => {
|
||||
const canvasElement = await page.getByRole('button', { name: 'Double click to set field' });
|
||||
await expect(canvasElement).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle scene pan and zoom when enabled', async ({ page }) => {
|
||||
// Feature toggle is enabled, pan/zoom functionality should be available
|
||||
const panZoomCheckbox = await page.getByLabel('Canvas Pan and zoom field').locator('label').nth(1);
|
||||
await panZoomCheckbox.setChecked(true);
|
||||
await expect(panZoomCheckbox).toBeChecked({ checked: true });
|
||||
|
||||
const canvasElement = await page.getByRole('button', { name: 'Double click to set field' });
|
||||
const canvasSceneWrapper = await page.getByTestId('canvas-scene-wrapper');
|
||||
|
||||
// Check if infinite viewer is present (pan/zoom feature)
|
||||
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
|
||||
const infiniteViewer = page.locator('[data-testid="canvas-scene-pan-zoom"]');
|
||||
await infiniteViewer.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await expect(await infiniteViewer.isVisible()).toBe(true);
|
||||
await infiniteViewer.hover();
|
||||
|
||||
const viewerBounds = await infiniteViewer.boundingBox();
|
||||
await expect(viewerBounds).toBeDefined();
|
||||
|
||||
// Test pan functionality
|
||||
const startX = viewerBounds.x + 50;
|
||||
const startY = viewerBounds.y + 50;
|
||||
const endX = viewerBounds.x + 250;
|
||||
const endY = viewerBounds.y + 250;
|
||||
await page.getByTestId('canvas-scene-pan-zoom');
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down({ button: 'middle' });
|
||||
await page.mouse.move(endX, endY);
|
||||
await page.mouse.up({ button: 'middle' });
|
||||
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(true);
|
||||
|
||||
// Test zoom reset with double-click
|
||||
await page.mouse.dblclick(startX, startY);
|
||||
// Verify canvas element is visible after pan/zoom operations
|
||||
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(false);
|
||||
|
||||
// Test zoom functionality
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.keyboard.down('Control');
|
||||
await page.mouse.wheel(0, -400); // Zoom in
|
||||
await page.keyboard.up('Control');
|
||||
// Check if canvas element is not visible after zoom operations
|
||||
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(true);
|
||||
|
||||
// Test zoom reset with double-click
|
||||
await page.mouse.dblclick(startX, startY);
|
||||
// Verify canvas element is visible after pan/zoom operations
|
||||
await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: this function is workaround for .toBeVisible()
|
||||
async function isOutsideViewport(element: Locator, viewPort: Locator): Promise<boolean> {
|
||||
const elementBounds = await element.boundingBox();
|
||||
const viewportBounds = await viewPort.boundingBox();
|
||||
return (
|
||||
elementBounds.x + elementBounds.width < viewportBounds.x ||
|
||||
elementBounds.x > viewportBounds.x + viewportBounds.width ||
|
||||
elementBounds.y + elementBounds.height < viewportBounds.y ||
|
||||
elementBounds.y > viewportBounds.y + viewportBounds.height
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -347,6 +347,7 @@
|
||||
"i18next-pseudo": "^2.2.1",
|
||||
"immer": "10.1.1",
|
||||
"immutable": "5.1.3",
|
||||
"infinite-viewer": "^0.29.1",
|
||||
"ix": "^7.0.0",
|
||||
"jquery": "3.7.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
@@ -406,7 +407,6 @@
|
||||
"react-virtualized-auto-sizer": "1.0.26",
|
||||
"react-window": "1.8.11",
|
||||
"react-window-infinite-loader": "1.0.10",
|
||||
"react-zoom-pan-pinch": "^3.3.0",
|
||||
"reduce-reducers": "^1.0.4",
|
||||
"redux": "5.0.1",
|
||||
"redux-thunk": "3.1.0",
|
||||
|
||||
+5
-5
@@ -117,10 +117,6 @@ export interface CanvasTooltip {
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Enable infinite pan
|
||||
*/
|
||||
infinitePan: boolean;
|
||||
/**
|
||||
* Enable inline editing
|
||||
*/
|
||||
@@ -155,11 +151,15 @@ export interface Options {
|
||||
* Controls tooltip options
|
||||
*/
|
||||
tooltip: CanvasTooltip;
|
||||
/**
|
||||
* Zoom to content
|
||||
*/
|
||||
zoomToContent: boolean;
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
infinitePan: true,
|
||||
inlineEditing: true,
|
||||
panZoom: true,
|
||||
showAdvancedTypes: true,
|
||||
zoomToContent: true,
|
||||
};
|
||||
|
||||
@@ -195,6 +195,15 @@ export default defineConfig<PluginOptions>({
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
{
|
||||
name: 'canvas',
|
||||
testDir: path.join(testDirRoot, '/canvas'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
{
|
||||
name: 'zipkin',
|
||||
testDir: path.join(pluginDirRoot, '/zipkin'),
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { Scene } from './scene';
|
||||
|
||||
type SceneTransformWrapperProps = {
|
||||
scene: Scene;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransformWrapperProps) => {
|
||||
const onZoom = (zoomPanPinchRef: ReactZoomPanPinchRef) => {
|
||||
const scale = zoomPanPinchRef.state.scale;
|
||||
scene.scale = scale;
|
||||
|
||||
if (scene.shouldInfinitePan) {
|
||||
const isScaleZoomedOut = scale < 1;
|
||||
|
||||
if (isScaleZoomedOut) {
|
||||
scene.updateSize(scene.width / scale, scene.height / scale);
|
||||
scene.panel.forceUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onZoomStop = (zoomPanPinchRef: ReactZoomPanPinchRef) => {
|
||||
const scale = zoomPanPinchRef.state.scale;
|
||||
scene.scale = scale;
|
||||
updateMoveable(scale);
|
||||
};
|
||||
|
||||
const onTransformed = (
|
||||
_: ReactZoomPanPinchRef,
|
||||
state: {
|
||||
scale: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
}
|
||||
) => {
|
||||
const scale = state.scale;
|
||||
scene.scale = scale;
|
||||
updateMoveable(scale);
|
||||
};
|
||||
|
||||
const updateMoveable = (scale: number) => {
|
||||
if (scene.moveable && scale > 0) {
|
||||
scene.moveable.zoom = 1 / scale;
|
||||
if (scale === 1) {
|
||||
scene.moveable.snappable = true;
|
||||
} else {
|
||||
scene.moveable.snappable = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPanning = (_: ReactZoomPanPinchRef, event: MouseEvent | TouchEvent) => {
|
||||
if (scene.shouldInfinitePan && event instanceof MouseEvent) {
|
||||
// Get deltaX and deltaY from pan event and add it to current canvas dimensions
|
||||
let deltaX = event.movementX;
|
||||
let deltaY = event.movementY;
|
||||
if (deltaX > 0) {
|
||||
deltaX = 0;
|
||||
}
|
||||
if (deltaY > 0) {
|
||||
deltaY = 0;
|
||||
}
|
||||
|
||||
// TODO: Consider bounding to the scene elements instead of allowing "infinite" panning
|
||||
// TODO: Consider making scene grow in all directions vs just down to the right / bottom
|
||||
scene.updateSize(scene.width - deltaX, scene.height - deltaY);
|
||||
scene.panel.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const onSceneContainerMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
// If pan and zoom is disabled or context menu is visible, don't pan
|
||||
if ((!scene.shouldPanZoom || scene.contextMenuVisible) && (e.button === 1 || (e.button === 2 && e.ctrlKey))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// If context menu is hidden, ignore left mouse or non-ctrl right mouse for pan
|
||||
if (!scene.contextMenuVisible && !scene.isPanelEditing && e.button === 2 && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Set panel content overflow to hidden to prevent canvas content from overflowing
|
||||
scene.div?.parentElement?.parentElement?.parentElement?.parentElement?.setAttribute('style', `overflow: hidden`);
|
||||
|
||||
return (
|
||||
<TransformWrapper
|
||||
doubleClick={{ mode: 'reset' }}
|
||||
ref={scene.transformComponentRef}
|
||||
onZoom={onZoom}
|
||||
onZoomStop={onZoomStop}
|
||||
onTransformed={onTransformed}
|
||||
disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom}
|
||||
panning={{ allowLeftClickPan: false }}
|
||||
limitToBounds={!scene.shouldInfinitePan}
|
||||
minScale={scene.shouldInfinitePan ? 0.1 : undefined}
|
||||
onPanning={onPanning}
|
||||
>
|
||||
<TransformComponent>
|
||||
{/* The <div> element has child elements that allow for mouse events, so we need to disable the linter rule */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div onMouseDown={onSceneContainerMouseDown}>{sceneDiv}</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { t } from '@grafana/i18n';
|
||||
import { TooltipDisplayMode } from '@grafana/schema';
|
||||
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
import { config } from 'app/core/config';
|
||||
import { notFoundItem } from 'app/features/canvas/elements/notFound';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import {
|
||||
@@ -25,7 +26,13 @@ import {
|
||||
Placement,
|
||||
VerticalConstraint,
|
||||
} from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
|
||||
import {
|
||||
applyStyles,
|
||||
getConnectionsByTarget,
|
||||
getRowIndex,
|
||||
isConnectionTarget,
|
||||
removeStyles,
|
||||
} from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { getActions, getActionsDefaultField } from '../../actions/utils';
|
||||
import { CanvasElementItem, CanvasElementOptions } from '../element';
|
||||
@@ -114,6 +121,10 @@ export class ElementState implements LayerElement {
|
||||
|
||||
/** Use the configured options to update CSS style properties directly on the wrapper div **/
|
||||
applyLayoutStylesToDiv(disablePointerEvents?: boolean) {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
this.applyLayoutStylesToDiv2(disablePointerEvents);
|
||||
return;
|
||||
}
|
||||
if (this.isRoot()) {
|
||||
// Root supersedes layout engine and is always 100% width + height of panel
|
||||
return;
|
||||
@@ -224,34 +235,166 @@ export class ElementState implements LayerElement {
|
||||
this.sizeStyle = style;
|
||||
|
||||
if (this.div) {
|
||||
for (const key in this.sizeStyle) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
this.div.style[key as any] = (this.sizeStyle as any)[key];
|
||||
}
|
||||
applyStyles(this.sizeStyle, this.div);
|
||||
|
||||
// TODO: This is a hack, we should have a better way to handle this
|
||||
const elementType = this.options.type;
|
||||
if (!SVGElements.has(elementType)) {
|
||||
// apply styles to div if it's not an SVG element
|
||||
for (const key in this.dataStyle) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
this.div.style[key as any] = (this.dataStyle as any)[key];
|
||||
}
|
||||
applyStyles(this.dataStyle, this.div);
|
||||
} else {
|
||||
// ELEMENT IS SVG
|
||||
// clean data styles from div if it's an SVG element; SVG elements have their own data styles;
|
||||
// this is necessary for changing type of element cases;
|
||||
// wrapper div element (this.div) doesn't re-render (has static `key` property),
|
||||
// so we have to clean styles manually;
|
||||
for (const key in this.dataStyle) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
this.div.style[key as any] = '';
|
||||
}
|
||||
removeStyles(this.dataStyle, this.div);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Use the configured options to update CSS style properties directly on the wrapper div **/
|
||||
applyLayoutStylesToDiv2(disablePointerEvents?: boolean) {
|
||||
if (this.isRoot()) {
|
||||
// Root supersedes layout engine and is always 100% width + height of panel
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = this.getScene();
|
||||
const { width: sceneWidth, height: sceneHeight } = scene ?? {};
|
||||
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
const placement: Placement = this.options.placement ?? {};
|
||||
|
||||
const editingEnabled = scene?.isEditingEnabled;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
cursor: editingEnabled ? 'grab' : 'auto',
|
||||
pointerEvents: disablePointerEvents ? 'none' : 'auto',
|
||||
position: 'absolute',
|
||||
// Minimum element size is 10x10
|
||||
minWidth: '10px',
|
||||
minHeight: '10px',
|
||||
};
|
||||
|
||||
let transformY = '0px';
|
||||
let transformX = '0px';
|
||||
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
this.options.placement = placement;
|
||||
style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`;
|
||||
this.sizeStyle = style;
|
||||
|
||||
if (this.div) {
|
||||
applyStyles(this.sizeStyle, this.div);
|
||||
|
||||
// TODO: This is a hack, we should have a better way to handle this
|
||||
const elementType = this.options.type;
|
||||
if (!SVGElements.has(elementType)) {
|
||||
// apply styles to div if it's not an SVG element
|
||||
applyStyles(this.dataStyle, this.div);
|
||||
} else {
|
||||
// ELEMENT IS SVG
|
||||
// clean data styles from div if it's an SVG element; SVG elements have their own data styles;
|
||||
// this is necessary for changing type of element cases;
|
||||
// wrapper div element (this.div) doesn't re-render (has static `key` property),
|
||||
// so we have to clean styles manually;
|
||||
removeStyles(this.dataStyle, this.div);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTopLeftValues(element: Element) {
|
||||
const style = window.getComputedStyle(element);
|
||||
const matrix = new DOMMatrix(style.transform || '');
|
||||
return {
|
||||
left: matrix.m41,
|
||||
top: matrix.m42,
|
||||
width: style.width ? parseFloat(style.width) : element.clientWidth,
|
||||
height: style.height ? parseFloat(style.height) : element.clientHeight,
|
||||
}; // m41 = translateX, m42 = translateY
|
||||
}
|
||||
|
||||
setPlacementFromConstraint(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
this.setPlacementFromConstraint2(elementContainer, parentContainer, transformScale);
|
||||
return;
|
||||
}
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
|
||||
@@ -389,6 +532,101 @@ export class ElementState implements LayerElement {
|
||||
this.getScene()?.save();
|
||||
}
|
||||
|
||||
setPlacementFromConstraint2(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) {
|
||||
const scene = this.getScene()!;
|
||||
const { constraint } = this.options;
|
||||
const { vertical, horizontal } = constraint ?? {};
|
||||
|
||||
const elementRect = this.getTopLeftValues(this.div!);
|
||||
|
||||
if (!elementContainer) {
|
||||
elementContainer = this.div && this.div.getBoundingClientRect();
|
||||
}
|
||||
// let parentBorderWidth = 0;
|
||||
if (!parentContainer) {
|
||||
parentContainer = this.div && this.div.parentElement?.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const relativeTop = Math.round(elementRect.top);
|
||||
const relativeBottom = Math.round(scene.height - elementRect.top - elementRect.height);
|
||||
const relativeLeft = Math.round(elementRect.left);
|
||||
const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width);
|
||||
|
||||
const placement: Placement = {};
|
||||
|
||||
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;
|
||||
|
||||
switch (vertical) {
|
||||
case VerticalConstraint.Top:
|
||||
placement.top = relativeTop;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.Bottom:
|
||||
placement.bottom = relativeBottom;
|
||||
placement.height = height;
|
||||
break;
|
||||
case VerticalConstraint.TopBottom:
|
||||
placement.top = relativeTop;
|
||||
placement.bottom = 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;
|
||||
break;
|
||||
case VerticalConstraint.Scale:
|
||||
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (horizontal) {
|
||||
case HorizontalConstraint.Left:
|
||||
placement.left = relativeLeft;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.Right:
|
||||
placement.right = relativeRight;
|
||||
placement.width = width;
|
||||
break;
|
||||
case HorizontalConstraint.LeftRight:
|
||||
placement.left = relativeLeft;
|
||||
placement.right = 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;
|
||||
break;
|
||||
case HorizontalConstraint.Scale:
|
||||
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.options.placement?.rotation) {
|
||||
placement.rotation = this.options.placement.rotation;
|
||||
placement.width = this.options.placement.width;
|
||||
placement.height = this.options.placement.height;
|
||||
}
|
||||
|
||||
this.options.placement = placement;
|
||||
|
||||
this.applyLayoutStylesToDiv();
|
||||
this.revId++;
|
||||
|
||||
this.getScene()?.save();
|
||||
}
|
||||
|
||||
updateData(ctx: DimensionContext) {
|
||||
if (this.item.prepareData) {
|
||||
this.data = this.item.prepareData(ctx, this.options);
|
||||
@@ -576,12 +814,12 @@ export class ElementState implements LayerElement {
|
||||
|
||||
// kinda like:
|
||||
// https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44
|
||||
applyResize = (event: OnResize, transformScale = 1) => {
|
||||
applyResize = (event: OnResize) => {
|
||||
const placement = this.options.placement!;
|
||||
|
||||
const style = event.target.style;
|
||||
let deltaX = event.delta[0] / transformScale;
|
||||
let deltaY = event.delta[1] / transformScale;
|
||||
let deltaX = event.delta[0];
|
||||
let deltaY = event.delta[1];
|
||||
let dirLR = event.direction[0];
|
||||
let dirTB = event.direction[1];
|
||||
|
||||
@@ -602,14 +840,22 @@ export class ElementState implements LayerElement {
|
||||
} else if (dirLR === -1) {
|
||||
placement.left! -= deltaX;
|
||||
placement.width = event.width;
|
||||
style.left = `${placement.left}px`;
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
|
||||
} else {
|
||||
style.left = `${placement.left}px`;
|
||||
}
|
||||
style.width = `${placement.width}px`;
|
||||
}
|
||||
|
||||
if (dirTB === -1) {
|
||||
placement.top! -= deltaY;
|
||||
placement.height = event.height;
|
||||
style.top = `${placement.top}px`;
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
|
||||
} else {
|
||||
style.top = `${placement.top}px`;
|
||||
}
|
||||
style.height = `${placement.height}px`;
|
||||
} else if (dirTB === 1) {
|
||||
placement.height = event.height;
|
||||
@@ -831,6 +1077,7 @@ export class ElementState implements LayerElement {
|
||||
onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<item.display
|
||||
key={`${this.UID}/${this.revId}`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import InfiniteViewer from 'infinite-viewer';
|
||||
import Moveable from 'moveable';
|
||||
import { createRef, CSSProperties, RefObject } from 'react';
|
||||
import { ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch';
|
||||
import { CSSProperties } from 'react';
|
||||
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
|
||||
import Selecto from 'selecto';
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
import { CanvasContextMenu } from 'app/plugins/panel/canvas/components/CanvasContextMenu';
|
||||
import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip';
|
||||
import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections';
|
||||
import { Connections2 } from 'app/plugins/panel/canvas/components/connections/Connections2';
|
||||
import { Options } from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||
import { AnchorPoint, CanvasTooltipPayload } from 'app/plugins/panel/canvas/types';
|
||||
import { getTransformInstance } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import appEvents from '../../../core/app_events';
|
||||
import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
|
||||
@@ -38,11 +38,10 @@ import { getDashboardSrv } from '../../dashboard/services/DashboardSrv';
|
||||
import { CanvasFrameOptions } from '../frame';
|
||||
import { DEFAULT_CANVAS_ELEMENT_CONFIG } from '../registry';
|
||||
|
||||
import { SceneTransformWrapper } from './SceneTransformWrapper';
|
||||
import { ElementState } from './element';
|
||||
import { FrameState } from './frame';
|
||||
import { RootElement } from './root';
|
||||
import { initMoveable } from './sceneAbleManagement';
|
||||
import { initMoveable, calculateZoomToFitScale } from './sceneAbleManagement';
|
||||
import { findElementByTarget } from './sceneElementManagement';
|
||||
|
||||
export interface SelectionParams {
|
||||
@@ -63,32 +62,30 @@ export class Scene {
|
||||
width = 0;
|
||||
height = 0;
|
||||
scale = 1;
|
||||
scrollLeft = 0;
|
||||
scrollTop = 0;
|
||||
style: CSSProperties = {};
|
||||
data?: PanelData;
|
||||
selecto?: Selecto;
|
||||
moveable?: Moveable;
|
||||
infiniteViewer?: InfiniteViewer;
|
||||
div?: HTMLDivElement;
|
||||
connections: Connections;
|
||||
viewerDiv?: HTMLDivElement;
|
||||
viewportDiv?: HTMLDivElement;
|
||||
connections: Connections | Connections2;
|
||||
currentLayer?: FrameState;
|
||||
isEditingEnabled?: boolean;
|
||||
shouldShowAdvancedTypes?: boolean;
|
||||
shouldPanZoom?: boolean;
|
||||
shouldInfinitePan?: boolean;
|
||||
zoomToContent?: boolean;
|
||||
tooltipMode?: TooltipDisplayMode;
|
||||
skipNextSelectionBroadcast = false;
|
||||
ignoreDataUpdate = false;
|
||||
panel: CanvasPanel;
|
||||
contextMenuVisible?: boolean;
|
||||
openContextMenu?: (position: AnchorPoint) => void;
|
||||
contextMenuOnVisibilityChange = (visible: boolean) => {
|
||||
this.contextMenuVisible = visible;
|
||||
const transformInstance = getTransformInstance(this);
|
||||
if (transformInstance) {
|
||||
if (visible) {
|
||||
transformInstance.setup.disabled = true;
|
||||
} else {
|
||||
transformInstance.setup.disabled = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
isPanelEditing = locationService.getSearchObject().editPanel !== undefined;
|
||||
@@ -107,7 +104,6 @@ export class Scene {
|
||||
subscription: Subscription;
|
||||
|
||||
targetsToSelect = new Set<HTMLDivElement>();
|
||||
transformComponentRef: RefObject<ReactZoomPanPinchContentRef> | undefined;
|
||||
|
||||
constructor(
|
||||
options: Options,
|
||||
@@ -129,8 +125,7 @@ export class Scene {
|
||||
});
|
||||
|
||||
this.panel = panel;
|
||||
this.connections = new Connections(this);
|
||||
this.transformComponentRef = createRef();
|
||||
this.connections = config.featureToggles.canvasPanelPanZoom ? new Connections2(this) : new Connections(this);
|
||||
}
|
||||
|
||||
getNextElementName = (isFrame = false) => {
|
||||
@@ -153,7 +148,7 @@ export class Scene {
|
||||
};
|
||||
|
||||
load(options: Options, enableEditing: boolean) {
|
||||
const { root, showAdvancedTypes, panZoom, infinitePan, tooltip } = options;
|
||||
const { root, showAdvancedTypes, panZoom, zoomToContent, tooltip } = options;
|
||||
const tooltipMode = tooltip?.mode ?? TooltipDisplayMode.Single;
|
||||
|
||||
this.root = new RootElement(
|
||||
@@ -168,18 +163,39 @@ export class Scene {
|
||||
this.isEditingEnabled = enableEditing;
|
||||
this.shouldShowAdvancedTypes = showAdvancedTypes;
|
||||
this.shouldPanZoom = panZoom;
|
||||
this.shouldInfinitePan = infinitePan;
|
||||
this.zoomToContent = zoomToContent;
|
||||
this.tooltipMode = tooltipMode;
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.div) {
|
||||
// If editing is enabled, clear selecto instance
|
||||
const destroySelecto = enableEditing;
|
||||
initMoveable(destroySelecto, enableEditing, this);
|
||||
this.currentLayer = this.root;
|
||||
this.selection.next([]);
|
||||
this.connections.select(undefined);
|
||||
this.connections.updateState();
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
if (this.viewportDiv && this.viewerDiv) {
|
||||
if (!this.shouldPanZoom) {
|
||||
this.scale = 1;
|
||||
this.scrollLeft = 0;
|
||||
this.scrollTop = 0;
|
||||
}
|
||||
|
||||
// If editing is enabled, clear selecto instance
|
||||
const destroySelecto = enableEditing;
|
||||
initMoveable(destroySelecto, enableEditing, this);
|
||||
this.currentLayer = this.root;
|
||||
this.selection.next([]);
|
||||
this.connections.select(undefined);
|
||||
this.connections.updateState();
|
||||
// update initial connections svg size
|
||||
this.updateConnectionsSize();
|
||||
this.fitContent(this, zoomToContent);
|
||||
}
|
||||
} else {
|
||||
if (this.div) {
|
||||
// If editing is enabled, clear selecto instance
|
||||
const destroySelecto = enableEditing;
|
||||
initMoveable(destroySelecto, enableEditing, this);
|
||||
this.currentLayer = this.root;
|
||||
this.selection.next([]);
|
||||
this.connections.select(undefined);
|
||||
this.connections.updateState();
|
||||
}
|
||||
}
|
||||
});
|
||||
return this.root;
|
||||
@@ -207,12 +223,53 @@ export class Scene {
|
||||
if (this.selecto?.getSelectedTargets().length) {
|
||||
this.clearCurrentSelection();
|
||||
}
|
||||
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
this.updateConnectionsSize();
|
||||
this.fitContent(this, this.zoomToContent!);
|
||||
|
||||
// TODO: This is a workaround to apply styles to the elements after the size update.
|
||||
// It's a good to go approach used by movable creator, but maybe we can find a better way.
|
||||
this.root.elements.forEach((el) => {
|
||||
el.applyLayoutStylesToDiv(false);
|
||||
});
|
||||
// TODO: This is a workaround to apply styles to the elements after the size update.
|
||||
// Remove this after dealing with the connection anchors stacking context issue.
|
||||
if (this.connections.connectionAnchorDiv) {
|
||||
this.connections.connectionAnchorDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionsSize() {
|
||||
const svgConnections = this.connections.connectionsSVG;
|
||||
|
||||
if (svgConnections) {
|
||||
const scale = this.infiniteViewer!.getZoom();
|
||||
// NOTE: sometimes getScrollLeft and getScrollTop return NaN,
|
||||
// so we use || 0 to ensure we have a valid number
|
||||
const left = this.infiniteViewer!.getScrollLeft() || 0;
|
||||
const top = this.infiniteViewer!.getScrollTop() || 0;
|
||||
const width = this.width;
|
||||
const height = this.height;
|
||||
|
||||
svgConnections.style.left = `${left}px`;
|
||||
svgConnections.style.top = `${top}px`;
|
||||
svgConnections.style.width = `${width / scale}px`;
|
||||
svgConnections.style.height = `${height / scale}px`;
|
||||
|
||||
svgConnections.setAttribute('viewBox', `${left} ${top} ${width / scale} ${height / scale}`);
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentSelection(skipNextSelectionBroadcast = false) {
|
||||
this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
|
||||
let event: MouseEvent = new MouseEvent('click');
|
||||
this.selecto?.clickTarget(event, this.div);
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
this.selecto?.clickTarget(event, this.viewportDiv);
|
||||
} else {
|
||||
this.selecto?.clickTarget(event, this.div);
|
||||
}
|
||||
}
|
||||
|
||||
save = (updateMoveable = false) => {
|
||||
@@ -220,8 +277,15 @@ export class Scene {
|
||||
|
||||
if (updateMoveable) {
|
||||
setTimeout(() => {
|
||||
if (this.div) {
|
||||
initMoveable(true, this.isEditingEnabled, this);
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
if (this.viewportDiv && this.viewerDiv) {
|
||||
initMoveable(true, this.isEditingEnabled, this);
|
||||
this.updateConnectionsSize();
|
||||
}
|
||||
} else {
|
||||
if (this.div) {
|
||||
initMoveable(true, this.isEditingEnabled, this);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -247,6 +311,14 @@ export class Scene {
|
||||
this.div = sceneContainer;
|
||||
};
|
||||
|
||||
setViewerRef = (viewerContainer: HTMLDivElement) => {
|
||||
this.viewerDiv = viewerContainer;
|
||||
};
|
||||
|
||||
setViewportRef = (viewportContainer: HTMLDivElement) => {
|
||||
this.viewportDiv = viewportContainer;
|
||||
};
|
||||
|
||||
select = (selection: SelectionParams) => {
|
||||
if (this.selecto) {
|
||||
this.selecto.setSelectedTargets(selection.targets);
|
||||
@@ -285,6 +357,16 @@ export class Scene {
|
||||
}
|
||||
};
|
||||
|
||||
fitContent = (scene: Scene, zoomToContent: boolean) => {
|
||||
const { root, viewerDiv, infiniteViewer } = scene;
|
||||
if (zoomToContent && root.div && infiniteViewer && viewerDiv) {
|
||||
const dimentions = calculateZoomToFitScale(Array.from(root.div.children), viewerDiv);
|
||||
const { scale, centerX, centerY } = dimentions;
|
||||
infiniteViewer.setZoom(scale);
|
||||
infiniteViewer.scrollTo(centerX, centerY);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const hasDataLinks = this.tooltipPayload?.element?.getLinks && this.tooltipPayload.element.getLinks({}).length > 0;
|
||||
const hasActions =
|
||||
@@ -295,7 +377,7 @@ export class Scene {
|
||||
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid && isTooltipEnabled;
|
||||
|
||||
const sceneDiv = (
|
||||
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
|
||||
<>
|
||||
{this.connections.render()}
|
||||
{this.root.render()}
|
||||
{this.isEditingEnabled && (
|
||||
@@ -312,13 +394,30 @@ export class Scene {
|
||||
<CanvasTooltip scene={this} />
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return config.featureToggles.canvasPanelPanZoom ? (
|
||||
<SceneTransformWrapper scene={this}>{sceneDiv}</SceneTransformWrapper>
|
||||
<div className={this.styles.viewer} ref={this.setViewerRef} key={this.revId} data-testid="canvas-scene-wrapper">
|
||||
<div
|
||||
className={this.styles.viewport}
|
||||
ref={this.setViewportRef}
|
||||
key={this.revId}
|
||||
data-testid="canvas-scene-pan-zoom"
|
||||
>
|
||||
{sceneDiv}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sceneDiv
|
||||
<div
|
||||
key={this.revId}
|
||||
className={this.styles.wrap}
|
||||
style={this.style}
|
||||
ref={this.setRef}
|
||||
data-testid="canvas-scene"
|
||||
>
|
||||
{sceneDiv}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -328,4 +427,16 @@ const getStyles = () => ({
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}),
|
||||
selected: css({
|
||||
zIndex: '999 !important',
|
||||
}),
|
||||
viewer: css({
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}),
|
||||
viewport: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import InfiniteViewer from 'infinite-viewer';
|
||||
import Moveable from 'moveable';
|
||||
import Selecto from 'selecto';
|
||||
|
||||
import { config } from 'app/core/config';
|
||||
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors';
|
||||
import {
|
||||
CONNECTION_VERTEX_ID,
|
||||
CONNECTION_VERTEX_ADD_ID,
|
||||
} from 'app/plugins/panel/canvas/components/connections/Connections';
|
||||
import { VerticalConstraint, HorizontalConstraint } from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||
import { getParent } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { dimensionViewable, constraintViewable, settingsViewable } from './ables';
|
||||
import { ElementState } from './element';
|
||||
@@ -15,6 +16,8 @@ import { FrameState } from './frame';
|
||||
import { Scene } from './scene';
|
||||
import { findElementByTarget } from './sceneElementManagement';
|
||||
|
||||
const ZOOM_RANGE = [0.1, 4]; // Minimum zoom 0.1x (10%), maximum zoom 4x (400%)
|
||||
|
||||
// Helper function that disables custom able functionality
|
||||
const disableCustomables = (moveable: Moveable) => {
|
||||
moveable!.props = {
|
||||
@@ -95,8 +98,8 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
}
|
||||
|
||||
scene.selecto = new Selecto({
|
||||
container: scene.div,
|
||||
rootContainer: getParent(scene),
|
||||
rootContainer: config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv : scene.div,
|
||||
dragContainer: config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv : scene.div,
|
||||
selectableTargets: targetElements,
|
||||
toggleContinueSelect: 'shift',
|
||||
selectFromInside: false,
|
||||
@@ -106,7 +109,7 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true };
|
||||
const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true };
|
||||
|
||||
scene.moveable = new Moveable(scene.div!, {
|
||||
scene.moveable = new Moveable(config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv! : scene.div!, {
|
||||
draggable: allowChanges && !scene.editModeEnabled.getValue(),
|
||||
resizable: allowChanges,
|
||||
|
||||
@@ -137,6 +140,12 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
|
||||
if (targetedElement) {
|
||||
targetedElement.applyRotate(event);
|
||||
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) {
|
||||
scene.moveableActionCallback(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('rotateGroup', (e) => {
|
||||
@@ -221,9 +230,7 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
e.events.forEach((event) => {
|
||||
const targetedElement = findElementByTarget(event.target, scene.root.elements);
|
||||
if (targetedElement) {
|
||||
if (targetedElement) {
|
||||
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
|
||||
}
|
||||
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
|
||||
|
||||
// re-add the selected elements to the snappable guidelines
|
||||
if (scene.moveable && scene.moveable.elementGuidelines) {
|
||||
@@ -280,11 +287,23 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
}
|
||||
}
|
||||
}
|
||||
// Temporarily set Top-Left constraints on each group element for predictable resizing; restore originals on end.
|
||||
for (let event of e.events) {
|
||||
const targetedElement = findElementByTarget(event.target, scene.root.elements);
|
||||
if (targetedElement) {
|
||||
targetedElement.tempConstraint = { ...targetedElement.options.constraint };
|
||||
targetedElement.options.constraint = {
|
||||
vertical: VerticalConstraint.Top,
|
||||
horizontal: HorizontalConstraint.Left,
|
||||
};
|
||||
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('resize', (event) => {
|
||||
const targetedElement = findElementByTarget(event.target, scene.root.elements);
|
||||
if (targetedElement) {
|
||||
targetedElement.applyResize(event, scene.scale);
|
||||
targetedElement.applyResize(event);
|
||||
|
||||
if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) {
|
||||
scene.moveableActionCallback(true);
|
||||
@@ -319,7 +338,6 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
targetedElement.options.constraint = targetedElement.tempConstraint;
|
||||
targetedElement.tempConstraint = undefined;
|
||||
}
|
||||
|
||||
targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale);
|
||||
|
||||
// re-add the selected element to the snappable guidelines
|
||||
@@ -409,4 +427,194 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene:
|
||||
.on('dragEnd', (event) => {
|
||||
clearTimeout(event.data.timer);
|
||||
});
|
||||
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
/******************/
|
||||
/* infiniteViewer */
|
||||
/******************/
|
||||
scene.infiniteViewer = new InfiniteViewer(scene.viewerDiv!, scene.viewportDiv!, {
|
||||
preventWheelClick: false,
|
||||
useAutoZoom: true,
|
||||
useMouseDrag: false, // `true` blocks metricValue dropdown
|
||||
useWheelScroll: scene.shouldPanZoom,
|
||||
displayHorizontalScroll: false,
|
||||
displayVerticalScroll: false,
|
||||
zoomRange: ZOOM_RANGE,
|
||||
});
|
||||
scene.infiniteViewer.setZoom(scene.scale);
|
||||
scene.infiniteViewer.scrollTo(scene.scrollLeft, scene.scrollTop);
|
||||
|
||||
// Handles context menu activation
|
||||
// Uses openContextMenu with coordinates when available (after CanvasContextMenu mounts), but
|
||||
// uses the basic visibility toggle when openContextMenu isn't ready (as a fallback)
|
||||
const triggerContextMenu = (x: number, y: number) => {
|
||||
if (scene.openContextMenu) {
|
||||
scene.openContextMenu({ x, y });
|
||||
} else {
|
||||
scene.contextMenuOnVisibilityChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
/* ----------------------------- EVENT HANDLERS ----------------------------- */
|
||||
// Helper for panning with mouse drag (middle mouse or Ctrl+right-click)
|
||||
// TODO: It was implemented as a workaround to unblock left click metricsValue dropdown,
|
||||
// but it should be replaced with a more robust solution that doesn't interfere with left click interactions.
|
||||
function startPanning(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startScrollLeft = scene.infiniteViewer!.getScrollLeft();
|
||||
const startScrollTop = scene.infiniteViewer!.getScrollTop();
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = startX - moveEvent.clientX;
|
||||
const deltaY = startY - moveEvent.clientY;
|
||||
const scaleAdjustedDeltaX = deltaX / scene.scale;
|
||||
const scaleAdjustedDeltaY = deltaY / scene.scale;
|
||||
scene.infiniteViewer!.scrollTo(startScrollLeft + scaleAdjustedDeltaX, startScrollTop + scaleAdjustedDeltaY);
|
||||
moveEvent.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
// Right click
|
||||
scene.viewerDiv!.addEventListener('contextmenu', (e) => {
|
||||
if (e.ctrlKey && e.button === 2 && scene.shouldPanZoom) {
|
||||
// Enable panning with Ctrl+right-click
|
||||
startPanning(e);
|
||||
} else {
|
||||
// Prevent default browser context menu
|
||||
e.preventDefault();
|
||||
triggerContextMenu(e.pageX, e.pageY);
|
||||
}
|
||||
});
|
||||
|
||||
// Enable panning with middle mouse button (wheel button)
|
||||
scene.viewerDiv!.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if (e.button === 1 && scene.shouldPanZoom) {
|
||||
// Middle mouse button
|
||||
startPanning(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent wheel scrolling when pan/zoom is disabled
|
||||
scene.viewportDiv!.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
if (!scene.shouldPanZoom) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Reset zoom and scroll position on double click
|
||||
scene.viewerDiv!.addEventListener('dblclick', (e: MouseEvent) => {
|
||||
// Only reset if not in edit mode and pan/zoom is enabled
|
||||
if (!scene.editModeEnabled.getValue() && scene.shouldPanZoom && scene.infiniteViewer) {
|
||||
scene.infiniteViewer.setZoom(1);
|
||||
scene.infiniteViewer.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse scroll click
|
||||
// Only allow panning with middle mouse button (button 1)
|
||||
// Left click is reserved for selection/manipulation, right click for context menu
|
||||
scene.infiniteViewer!.on('dragStart', (e) => {
|
||||
if (e.inputEvent.button !== 1) {
|
||||
e.preventDefault();
|
||||
e.preventDrag();
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll
|
||||
scene.infiniteViewer!.on('scroll', () => {
|
||||
// TODO: clear current selection is default behaviour on zoom-in or zoom-out,
|
||||
// but looks like we prevented this event to trigger at some point
|
||||
scene.clearCurrentSelection(true);
|
||||
|
||||
scene.updateConnectionsSize();
|
||||
scene.scale = scene.infiniteViewer!.getZoom();
|
||||
|
||||
scene.scrollLeft = scene.infiniteViewer!.getScrollLeft();
|
||||
scene.scrollTop = scene.infiniteViewer!.getScrollTop();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Zoom to content helper functions
|
||||
export function calculateZoomToFitScale(elements: Element[], container: HTMLDivElement, paddingRatio = 0.05) {
|
||||
const bounds = calculateGroupBoundingBox(elements);
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
const containerHeight = containerRect.height;
|
||||
|
||||
const paddedWidth = containerWidth * (1 - 2 * paddingRatio);
|
||||
const paddedHeight = containerHeight * (1 - 2 * paddingRatio);
|
||||
|
||||
const scaleX = paddedWidth / bounds.width;
|
||||
const scaleY = paddedHeight / bounds.height;
|
||||
|
||||
// Use the smaller one to fit both horizontally and vertically
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
// calculate value to move to center
|
||||
const centerX = (bounds.centerX * scale - containerWidth / 2) / scale;
|
||||
const centerY = (bounds.centerY * scale - containerHeight / 2) / scale;
|
||||
|
||||
return {
|
||||
scale,
|
||||
centerX,
|
||||
centerY,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractTranslateFromTransform(transform: string) {
|
||||
const matrix = new DOMMatrix(transform);
|
||||
return { x: matrix.m41, y: matrix.m42 }; // m41 = translateX, m42 = translateY
|
||||
}
|
||||
|
||||
export function calculateGroupBoundingBox(elements: Element[]) {
|
||||
let minX = Infinity,
|
||||
minY = Infinity;
|
||||
let maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const el of elements) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const { x: tx, y: ty } = extractTranslateFromTransform(style.transform || '');
|
||||
|
||||
const width = parseFloat(style.width);
|
||||
const height = parseFloat(style.height);
|
||||
|
||||
const left = tx;
|
||||
const top = ty;
|
||||
const right = tx + width;
|
||||
const bottom = ty + height;
|
||||
|
||||
minX = Math.min(minX, left);
|
||||
minY = Math.min(minY, top);
|
||||
maxX = Math.max(maxX, right);
|
||||
maxY = Math.max(maxY, bottom);
|
||||
}
|
||||
|
||||
return {
|
||||
left: minX,
|
||||
top: minY,
|
||||
right: maxX,
|
||||
bottom: maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
centerX: (minX + maxX) / 2,
|
||||
centerY: (minY + maxY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
|
||||
readonly scene: Scene;
|
||||
private subs = new Subscription();
|
||||
private queryEditorLoaded = false;
|
||||
needsReload = false;
|
||||
isEditing = locationService.getSearchObject().editPanel !== undefined;
|
||||
|
||||
@@ -97,10 +98,7 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
|
||||
this.panelContext = this.context;
|
||||
if (this.panelContext.onInstanceStateChange) {
|
||||
this.panelContext.onInstanceStateChange({
|
||||
scene: this.scene,
|
||||
layer: this.scene.root,
|
||||
});
|
||||
this.panelContext.onInstanceStateChange({ scene: this.scene, layer: this.scene.root });
|
||||
|
||||
this.subs.add(
|
||||
this.scene.selection.subscribe({
|
||||
@@ -117,11 +115,7 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
}
|
||||
});
|
||||
|
||||
this.panelContext?.onInstanceStateChange!({
|
||||
scene: this.scene,
|
||||
selected: v,
|
||||
layer: this.scene.root,
|
||||
});
|
||||
this.panelContext?.onInstanceStateChange!({ scene: this.scene, selected: v, layer: this.scene.root });
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -160,9 +154,29 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
// Reset the size update flag when entering edit mode
|
||||
if (this.isEditing) {
|
||||
this.queryEditorLoaded = false;
|
||||
}
|
||||
|
||||
canvasInstances.push(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// Check if we're in edit mode and dimensions have changed (indicating query editor loaded)
|
||||
if (this.isEditing && !this.queryEditorLoaded) {
|
||||
const dimensionsChanged = prevProps.width !== this.props.width || prevProps.height !== this.props.height;
|
||||
|
||||
if (dimensionsChanged) {
|
||||
this.queryEditorLoaded = true;
|
||||
// Small delay to ensure layout is completely settled
|
||||
requestAnimationFrame(() => {
|
||||
this.scene.updateSize(this.props.width, this.props.height);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.scene.subscription.unsubscribe();
|
||||
this.subs.unsubscribe();
|
||||
@@ -175,10 +189,7 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
// even the editor gets current state from the same scene instance!
|
||||
onUpdateScene = (root: CanvasFrameOptions) => {
|
||||
const { onOptionsChange, options } = this.props;
|
||||
onOptionsChange({
|
||||
...options,
|
||||
root,
|
||||
});
|
||||
onOptionsChange({ ...options, root });
|
||||
|
||||
this.setState({ refresh: this.state.refresh + 1 });
|
||||
activePanelSubject.next({ panel: this });
|
||||
@@ -224,14 +235,14 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
const shouldShowAdvancedTypesSwitched =
|
||||
this.props.options.showAdvancedTypes !== nextProps.options.showAdvancedTypes;
|
||||
const panZoomSwitched = this.props.options.panZoom !== nextProps.options.panZoom;
|
||||
const infinitePanSwitched = this.props.options.infinitePan !== nextProps.options.infinitePan;
|
||||
const zoomToContentSwitched = this.props.options.zoomToContent !== nextProps.options.zoomToContent;
|
||||
const tooltipModeSwitched = this.props.options.tooltip?.mode !== nextProps.options.tooltip?.mode;
|
||||
if (
|
||||
this.needsReload ||
|
||||
inlineEditingSwitched ||
|
||||
shouldShowAdvancedTypesSwitched ||
|
||||
panZoomSwitched ||
|
||||
infinitePanSwitched ||
|
||||
zoomToContentSwitched ||
|
||||
tooltipModeSwitched
|
||||
) {
|
||||
if (inlineEditingSwitched) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { first } from 'rxjs/operators';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ContextMenu, MenuItem, MenuItemProps } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { FrameState } from 'app/features/canvas/runtime/frame';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
@@ -31,6 +32,20 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) =
|
||||
const selectedElements = scene.selecto?.getSelectedTargets();
|
||||
const rootLayer: FrameState | undefined = panel.context?.instanceState?.layer;
|
||||
|
||||
useEffect(() => {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
scene.openContextMenu = (position: AnchorPoint) => {
|
||||
setAnchorPoint(position);
|
||||
setIsMenuVisible(true);
|
||||
onVisibilityChange(true);
|
||||
};
|
||||
|
||||
// Clean up the openContextMenu on unmount
|
||||
return () => (scene.openContextMenu = undefined);
|
||||
}
|
||||
return undefined;
|
||||
}, [scene, onVisibilityChange]);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: Event) => {
|
||||
if (!(event instanceof MouseEvent) || event.ctrlKey) {
|
||||
@@ -40,7 +55,9 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) =
|
||||
event.preventDefault();
|
||||
panel.setActivePanel();
|
||||
|
||||
const shouldSelectElement = event.currentTarget !== scene.div;
|
||||
const shouldSelectElement = config.featureToggles.canvasPanelPanZoom
|
||||
? event.currentTarget !== scene.viewportDiv
|
||||
: event.currentTarget !== scene.div;
|
||||
if (
|
||||
shouldSelectElement &&
|
||||
(event.currentTarget instanceof HTMLElement || event.currentTarget instanceof SVGElement)
|
||||
@@ -133,6 +150,10 @@ export const CanvasContextMenu = ({ scene, panel, onVisibilityChange }: Props) =
|
||||
const sceneContainerDimensions = scene.div.getBoundingClientRect();
|
||||
offsetY = (offsetY - sceneContainerDimensions.top) / transformScale;
|
||||
offsetX = (offsetX - sceneContainerDimensions.left) / transformScale;
|
||||
} else if (scene.viewportDiv) {
|
||||
const sceneContainerDimensions = scene.viewportDiv.getBoundingClientRect();
|
||||
offsetY -= sceneContainerDimensions.top;
|
||||
offsetX -= sceneContainerDimensions.left;
|
||||
}
|
||||
|
||||
onAddItem(option, rootLayer, {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useRef } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { ConnectionCoordinates } from 'app/features/canvas/element';
|
||||
|
||||
type Props = {
|
||||
setRef: (anchorElement: HTMLDivElement) => void;
|
||||
setAnchorsRef: (anchorsElement: HTMLDivElement) => void;
|
||||
handleMouseLeave: (
|
||||
event: React.MouseEvent<Element, MouseEvent> | React.FocusEvent<HTMLDivElement, Element>
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
export const CONNECTION_ANCHOR_DIV_ID = 'connectionControl';
|
||||
export const CONNECTION_ANCHOR_ALT = 'connection anchor';
|
||||
export const CONNECTION_ANCHOR_HIGHLIGHT_OFFSET = 8;
|
||||
// Unit is percentage from the middle of the element
|
||||
// 0, 0 middle; -1, -1 bottom left; 1, 1 top right
|
||||
export const ANCHORS = [
|
||||
{ x: -1, y: 1 },
|
||||
{ x: -0.5, y: 1 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0.5, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 0.5 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: -0.5 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0.5, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
{ x: -0.5, y: -1 },
|
||||
{ x: -1, y: -1 },
|
||||
{ x: -1, y: -0.5 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: -1, y: 0.5 },
|
||||
];
|
||||
export const ANCHOR_PADDING = 3;
|
||||
export const HALF_SIZE = 2.5;
|
||||
|
||||
const zIndex = {
|
||||
ROOT: 1000,
|
||||
ANCHOR: 1001,
|
||||
HIGHLIGHT: 1002,
|
||||
};
|
||||
|
||||
enum PointerEvents {
|
||||
ROOT = 'none',
|
||||
MOUSEOUT_DIV = 'none',
|
||||
ANCHOR = 'auto',
|
||||
HIGHLIGHT = 'auto',
|
||||
}
|
||||
|
||||
export const ConnectionAnchors = ({ setRef, setAnchorsRef, handleMouseLeave }: Props) => {
|
||||
const highlightEllipseRef = useRef<HTMLDivElement>(null);
|
||||
const styles = useStyles2(getStyles);
|
||||
const halfSizeHighlightEllipse = 5.5;
|
||||
const anchorImage =
|
||||
'data:image/svg+xml;base64,PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSI1cHgiIGhlaWdodD0iNXB4IiB2ZXJzaW9uPSIxLjEiPjxwYXRoIGQ9Im0gMCAwIEwgNSA1IE0gMCA1IEwgNSAwIiBzdHJva2Utd2lkdGg9IjIiIHN0eWxlPSJzdHJva2Utb3BhY2l0eTowLjQiIHN0cm9rZT0iI2ZmZmZmZiIvPjxwYXRoIGQ9Im0gMCAwIEwgNSA1IE0gMCA1IEwgNSAwIiBzdHJva2U9IiMyOWI2ZjIiLz48L3N2Zz4=';
|
||||
|
||||
const onMouseEnterAnchor = (event: React.MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (highlightEllipseRef.current && event.target.style) {
|
||||
highlightEllipseRef.current.style.display = 'block';
|
||||
highlightEllipseRef.current.style.top = `calc(${event.target.style.top} - ${halfSizeHighlightEllipse}px + ${ANCHOR_PADDING}px)`;
|
||||
highlightEllipseRef.current.style.left = `calc(${event.target.style.left} - ${halfSizeHighlightEllipse}px + ${ANCHOR_PADDING}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeaveHighlightElement = () => {
|
||||
if (highlightEllipseRef.current) {
|
||||
highlightEllipseRef.current.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeaveAnchors = (
|
||||
event: React.MouseEvent<Element, MouseEvent> | React.FocusEvent<HTMLDivElement, Element>
|
||||
) => {
|
||||
const didHideAnchors = handleMouseLeave(event);
|
||||
|
||||
if (didHideAnchors) {
|
||||
onMouseLeaveHighlightElement();
|
||||
}
|
||||
};
|
||||
|
||||
const generateAnchors = (anchors: ConnectionCoordinates[] = ANCHORS) => {
|
||||
return anchors.map((anchor) => {
|
||||
const id = `${anchor.x},${anchor.y}`;
|
||||
|
||||
// Convert anchor coords to relative percentage
|
||||
const style = {
|
||||
top: `calc(${-anchor.y * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`,
|
||||
left: `calc(${anchor.x * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
id={id}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
// After React 15+, inline styles no longer support !important
|
||||
element.style.setProperty('pointer-events', PointerEvents.ANCHOR, 'important');
|
||||
}
|
||||
}}
|
||||
key={id}
|
||||
alt={CONNECTION_ANCHOR_ALT}
|
||||
className={styles.anchor}
|
||||
style={style}
|
||||
src={anchorImage}
|
||||
onMouseEnter={onMouseEnterAnchor}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={setRef}>
|
||||
<div className={styles.mouseoutDiv} onMouseOut={handleMouseLeaveAnchors} onBlur={handleMouseLeaveAnchors} />
|
||||
<div
|
||||
id={CONNECTION_ANCHOR_DIV_ID}
|
||||
ref={highlightEllipseRef}
|
||||
className={styles.highlightElement}
|
||||
onMouseLeave={onMouseLeaveHighlightElement}
|
||||
/>
|
||||
<div ref={setAnchorsRef}>{generateAnchors()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
root: css({
|
||||
position: 'absolute',
|
||||
display: 'none',
|
||||
zIndex: `${zIndex.ROOT} !important`,
|
||||
pointerEvents: PointerEvents.ROOT,
|
||||
}),
|
||||
mouseoutDiv: css({
|
||||
position: 'absolute',
|
||||
margin: '-30px',
|
||||
width: 'calc(100% + 60px)',
|
||||
height: 'calc(100% + 60px)',
|
||||
pointerEvents: PointerEvents.MOUSEOUT_DIV,
|
||||
}),
|
||||
anchor: css({
|
||||
padding: `${ANCHOR_PADDING}px`,
|
||||
position: 'absolute',
|
||||
cursor: 'cursor',
|
||||
width: `calc(5px + 2 * ${ANCHOR_PADDING}px)`,
|
||||
height: `calc(5px + 2 * ${ANCHOR_PADDING}px)`,
|
||||
zIndex: `${zIndex.ANCHOR} !important`,
|
||||
pointerEvents: PointerEvents.ANCHOR,
|
||||
userSelect: 'none',
|
||||
}),
|
||||
highlightElement: css({
|
||||
backgroundColor: '#00ff00',
|
||||
opacity: 0.3,
|
||||
position: 'absolute',
|
||||
cursor: 'cursor',
|
||||
pointerEvents: PointerEvents.HIGHLIGHT,
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
display: 'none',
|
||||
zIndex: `${zIndex.HIGHLIGHT} !important`,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,596 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
import { ConnectionDirection } from 'app/features/canvas/element';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
|
||||
import { ConnectionCoordinates } from '../../panelcfg.gen';
|
||||
import { ConnectionState } from '../../types';
|
||||
import {
|
||||
calculateAngle,
|
||||
calculateCoordinates2,
|
||||
calculateDistance,
|
||||
calculateMidpoint,
|
||||
getConnectionStyles,
|
||||
} from '../../utils';
|
||||
|
||||
import { CONNECTION_VERTEX_ADD_ID, CONNECTION_VERTEX_ID } from './Connections';
|
||||
|
||||
type Props = {
|
||||
setLineRef: (anchorElement: SVGLineElement) => void;
|
||||
setVertexPathRef: (anchorElement: SVGPathElement) => void;
|
||||
setVertexRef: (anchorElement: SVGCircleElement) => void;
|
||||
setConnectionsSVGRef: (anchorElement: SVGSVGElement) => void;
|
||||
scene: Scene;
|
||||
};
|
||||
|
||||
let idCounter = 0;
|
||||
const htmlElementTypes = ['input', 'textarea'];
|
||||
|
||||
export const ConnectionSVG = ({ setLineRef, setVertexPathRef, setVertexRef, setConnectionsSVGRef, scene }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const headId = Date.now() + '_' + idCounter++;
|
||||
const CONNECTION_LINE_ID = useMemo(() => `connectionLineId-${headId}`, [headId]);
|
||||
const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]);
|
||||
const defaultArrowColor = config.theme2.colors.text.primary;
|
||||
const defaultArrowSize = 2;
|
||||
const defaultArrowDirection = ConnectionDirection.Forward;
|
||||
const maximumVertices = 10;
|
||||
|
||||
const [selectedConnection, setSelectedConnection] = useState<ConnectionState | undefined>(undefined);
|
||||
|
||||
// Need to use ref to ensure state is not stale in event handler
|
||||
const selectedConnectionRef = useRef(selectedConnection);
|
||||
useEffect(() => {
|
||||
selectedConnectionRef.current = selectedConnection;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (scene.panel.context.instanceState?.selectedConnection) {
|
||||
setSelectedConnection(scene.panel.context.instanceState?.selectedConnection);
|
||||
}
|
||||
}, [scene.panel.context.instanceState?.selectedConnection]);
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
const target = e.target;
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (htmlElementTypes.indexOf(target.nodeName.toLowerCase()) > -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace (8) or delete (46)
|
||||
if (e.keyCode === 8 || e.keyCode === 46) {
|
||||
if (selectedConnectionRef.current && selectedConnectionRef.current.source) {
|
||||
selectedConnectionRef.current.source.options.connections =
|
||||
selectedConnectionRef.current.source.options.connections?.filter(
|
||||
(connection) => connection !== selectedConnectionRef.current?.info
|
||||
);
|
||||
selectedConnectionRef.current.source.onChange(selectedConnectionRef.current.source.options);
|
||||
|
||||
setSelectedConnection(undefined);
|
||||
scene.connections.select(undefined);
|
||||
scene.connections.updateState();
|
||||
scene.save();
|
||||
}
|
||||
} else {
|
||||
// Prevent removing event listener if key is not delete
|
||||
return;
|
||||
}
|
||||
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
scene.selecto!.rootContainer!.removeEventListener('click', clearSelectedConnection);
|
||||
};
|
||||
|
||||
const clearSelectedConnection = (event: MouseEvent) => {
|
||||
const eventTarget = event.target;
|
||||
|
||||
const shouldResetSelectedConnection = !(
|
||||
eventTarget instanceof SVGLineElement && eventTarget.id === CONNECTION_LINE_ID
|
||||
);
|
||||
|
||||
if (shouldResetSelectedConnection) {
|
||||
setSelectedConnection(undefined);
|
||||
scene.connections.select(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const selectConnection = (connection: ConnectionState) => {
|
||||
if (scene.isEditingEnabled) {
|
||||
setSelectedConnection(connection);
|
||||
scene.connections.select(connection);
|
||||
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
scene.selecto!.rootContainer!.addEventListener('click', clearSelectedConnection);
|
||||
}
|
||||
};
|
||||
|
||||
// Figure out target and then target's relative coordinates drawing (if no target do parent)
|
||||
const renderConnections = () => {
|
||||
return (
|
||||
scene.connections.state
|
||||
// Render selected connection last, ensuring it is above other connections
|
||||
.sort((_a, b) => (selectedConnection === b && scene.panel.context.instanceState.selectedConnection ? -1 : 0))
|
||||
.map((v, idx) => {
|
||||
const { source, target, info, vertices, index } = v;
|
||||
const sourceRect = source.div;
|
||||
const parent = source.div?.parentElement;
|
||||
const parentRect = scene.viewportDiv;
|
||||
|
||||
if (!sourceRect || !parent || !parentRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let { x1, y1, x2, y2 } = calculateCoordinates2(source, target, info);
|
||||
|
||||
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
|
||||
if (v.sourceOriginal && v.targetOriginal) {
|
||||
xStart = v.sourceOriginal.x;
|
||||
yStart = v.sourceOriginal.y;
|
||||
xEnd = v.targetOriginal.x;
|
||||
yEnd = v.targetOriginal.y;
|
||||
} else if (source.options.connections) {
|
||||
// If original source or target coordinates are not set for the current connection, set them
|
||||
if (
|
||||
!source.options.connections[index].sourceOriginal ||
|
||||
!source.options.connections[index].targetOriginal
|
||||
) {
|
||||
source.options.connections[index].sourceOriginal = { x: x1, y: y1 };
|
||||
source.options.connections[index].targetOriginal = { x: x2, y: y2 };
|
||||
}
|
||||
}
|
||||
|
||||
const midpoint = calculateMidpoint(x1, y1, x2, y2);
|
||||
const xDist = xEnd - xStart;
|
||||
const yDist = yEnd - yStart;
|
||||
|
||||
const { strokeColor, strokeWidth, strokeRadius, arrowDirection, lineStyle, shouldAnimate } =
|
||||
getConnectionStyles(info, scene, defaultArrowSize, defaultArrowDirection);
|
||||
|
||||
const isSelected = selectedConnection === v && scene.panel.context.instanceState.selectedConnection;
|
||||
|
||||
const connectionCursorStyle = scene.isEditingEnabled ? 'grab' : '';
|
||||
const selectedStyles = { stroke: '#44aaff', strokeOpacity: 0.6, strokeWidth: strokeWidth + 5 };
|
||||
|
||||
const CONNECTION_HEAD_ID_START = `connectionHeadStart-${headId + Math.random()}`;
|
||||
const CONNECTION_HEAD_ID_END = `connectionHeadEnd-${headId + Math.random()}`;
|
||||
|
||||
const radius = strokeRadius;
|
||||
// Create vertex path and populate array of add vertex controls
|
||||
const addVertices: ConnectionCoordinates[] = [];
|
||||
let pathString = `M${x1} ${y1} `;
|
||||
if (vertices?.length) {
|
||||
vertices.map((vertex, index) => {
|
||||
const { x, y } = vertex;
|
||||
|
||||
// Convert vertex relative coordinates to scene coordinates
|
||||
const X = x * xDist + xStart;
|
||||
const Y = y * yDist + yStart;
|
||||
|
||||
// Initialize coordinates for first arc control point
|
||||
let xa = X;
|
||||
let ya = Y;
|
||||
|
||||
// Initialize coordinates for second arc control point
|
||||
let xb = X;
|
||||
let yb = Y;
|
||||
|
||||
// Initialize half arc distance and segment angles
|
||||
let lHalfArc = 0;
|
||||
let angle1 = 0;
|
||||
let angle2 = 0;
|
||||
|
||||
// Only calculate arcs if there is a radius
|
||||
if (radius) {
|
||||
if (index < vertices.length - 1) {
|
||||
const Xn = vertices[index + 1].x * xDist + xStart;
|
||||
const Yn = vertices[index + 1].y * yDist + yStart;
|
||||
if (index === 0) {
|
||||
// First vertex
|
||||
angle1 = calculateAngle(x1, y1, X, Y);
|
||||
angle2 = calculateAngle(X, Y, Xn, Yn);
|
||||
} else {
|
||||
// All vertices
|
||||
const previousVertex = vertices[index - 1];
|
||||
const Xp = previousVertex.x * xDist + xStart;
|
||||
const Yp = previousVertex.y * yDist + yStart;
|
||||
angle1 = calculateAngle(Xp, Yp, X, Y);
|
||||
angle2 = calculateAngle(X, Y, Xn, Yn);
|
||||
}
|
||||
} else {
|
||||
// Last vertex
|
||||
if (index > 0) {
|
||||
// Not also the first vertex
|
||||
const previousVertex = vertices[index - 1];
|
||||
const Xp = previousVertex.x * xDist + xStart;
|
||||
const Yp = previousVertex.y * yDist + yStart;
|
||||
angle1 = calculateAngle(Xp, Yp, X, Y);
|
||||
} else {
|
||||
angle1 = calculateAngle(x1, y1, X, Y);
|
||||
}
|
||||
angle2 = calculateAngle(X, Y, x2, y2);
|
||||
}
|
||||
|
||||
// Calculate angle between two segments where arc will be placed
|
||||
const theta = angle2 - angle1; //radians
|
||||
// Attempt to determine if arc is counter clockwise (ccw)
|
||||
const ccw = theta < 0;
|
||||
// Half arc is used for arc control points
|
||||
lHalfArc = radius * Math.tan(theta / 2);
|
||||
if (ccw) {
|
||||
lHalfArc *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
// For first vertex
|
||||
addVertices.push(
|
||||
calculateMidpoint((x1 - xStart) / (xEnd - xStart), (y1 - yStart) / (yEnd - yStart), x, y)
|
||||
);
|
||||
|
||||
// Only calculate arcs if there is a radius
|
||||
if (radius) {
|
||||
// Length of segment
|
||||
const lSegment = calculateDistance(X, Y, x1, y1);
|
||||
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) {
|
||||
// Limit curve control points to mid segment
|
||||
lHalfArc = 0.5 * lSegment;
|
||||
}
|
||||
// Default next point to last point
|
||||
let Xn = x2;
|
||||
let Yn = y2;
|
||||
if (index < vertices.length - 1) {
|
||||
// Not also the last point
|
||||
const nextVertex = vertices[index + 1];
|
||||
Xn = nextVertex.x * xDist + xStart;
|
||||
Yn = nextVertex.y * yDist + yStart;
|
||||
}
|
||||
|
||||
// Length of next segment
|
||||
const lSegmentNext = calculateDistance(X, Y, Xn, Yn);
|
||||
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegmentNext)) {
|
||||
// Limit curve control points to mid segment
|
||||
lHalfArc = 0.5 * lSegmentNext;
|
||||
}
|
||||
// Calculate arc control points
|
||||
const lDelta = lSegment - lHalfArc;
|
||||
xa = Math.round(lDelta * Math.cos(angle1) + x1);
|
||||
ya = Math.round(lDelta * Math.sin(angle1) + y1);
|
||||
xb = Math.round(lHalfArc * Math.cos(angle2) + X);
|
||||
yb = Math.round(lHalfArc * Math.sin(angle2) + Y);
|
||||
|
||||
// Check if arc control points are inside of segment, otherwise swap sign
|
||||
if ((xa > X && xa > x1) || (xa < X && xa < x1)) {
|
||||
xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + x1;
|
||||
ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + y1;
|
||||
xb = -lHalfArc * Math.cos(angle2) + X;
|
||||
yb = -lHalfArc * Math.sin(angle2) + Y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For all other vertices
|
||||
const previousVertex = vertices[index - 1];
|
||||
addVertices.push(calculateMidpoint(previousVertex.x, previousVertex.y, x, y));
|
||||
|
||||
// Only calculate arcs if there is a radius
|
||||
if (radius) {
|
||||
// Convert previous vertex relative coorindates to scene coordinates
|
||||
const Xp = previousVertex.x * xDist + xStart;
|
||||
const Yp = previousVertex.y * yDist + yStart;
|
||||
|
||||
// Length of segment
|
||||
const lSegment = calculateDistance(X, Y, Xp, Yp);
|
||||
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) {
|
||||
// Limit curve control points to mid segment
|
||||
lHalfArc = 0.5 * lSegment;
|
||||
}
|
||||
// Default next point to last point
|
||||
let Xn = x2;
|
||||
let Yn = y2;
|
||||
if (index < vertices.length - 1) {
|
||||
// Not also the last point
|
||||
const nextVertex = vertices[index + 1];
|
||||
Xn = nextVertex.x * xDist + xStart;
|
||||
Yn = nextVertex.y * yDist + yStart;
|
||||
}
|
||||
|
||||
// Length of next segment
|
||||
const lSegmentNext = calculateDistance(X, Y, Xn, Yn);
|
||||
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegmentNext)) {
|
||||
// Limit curve control points to mid segment
|
||||
lHalfArc = 0.5 * lSegmentNext;
|
||||
}
|
||||
|
||||
// Calculate arc control points
|
||||
const lDelta = lSegment - lHalfArc;
|
||||
xa = Math.round(lDelta * Math.cos(angle1) + Xp);
|
||||
ya = Math.round(lDelta * Math.sin(angle1) + Yp);
|
||||
xb = Math.round(lHalfArc * Math.cos(angle2) + X);
|
||||
yb = Math.round(lHalfArc * Math.sin(angle2) + Y);
|
||||
|
||||
// Check if arc control points are inside of segment, otherwise swap sign
|
||||
if ((xa > X && xa > Xp) || (xa < X && xa < Xp)) {
|
||||
xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + Xp;
|
||||
ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + Yp;
|
||||
xb = -lHalfArc * Math.cos(angle2) + X;
|
||||
yb = -lHalfArc * Math.sin(angle2) + Y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index === vertices.length - 1) {
|
||||
// For last vertex only
|
||||
addVertices.push(
|
||||
calculateMidpoint((x2 - xStart) / (xEnd - xStart), (y2 - yStart) / (yEnd - yStart), x, y)
|
||||
);
|
||||
}
|
||||
// Add segment to path
|
||||
pathString += `L${xa} ${ya} `;
|
||||
|
||||
if (lHalfArc !== 0) {
|
||||
// Add arc if applicable
|
||||
pathString += `Q ${X} ${Y} ${xb} ${yb} `;
|
||||
}
|
||||
});
|
||||
// Add last segment
|
||||
pathString += `L${x2} ${y2}`;
|
||||
}
|
||||
|
||||
const markerStart =
|
||||
arrowDirection === ConnectionDirection.Reverse || arrowDirection === ConnectionDirection.Both
|
||||
? `url(#${CONNECTION_HEAD_ID_START})`
|
||||
: undefined;
|
||||
|
||||
const markerEnd =
|
||||
arrowDirection === ConnectionDirection.Forward || arrowDirection === ConnectionDirection.Both
|
||||
? `url(#${CONNECTION_HEAD_ID_END})`
|
||||
: undefined;
|
||||
|
||||
const getAnimationDirection = () => {
|
||||
let values = '100;0';
|
||||
|
||||
if (arrowDirection === ConnectionDirection.Reverse) {
|
||||
values = '0;100';
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
return (
|
||||
<g key={idx} onClick={() => selectConnection(v)}>
|
||||
<defs>
|
||||
<marker
|
||||
id={CONNECTION_HEAD_ID_START}
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="0"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
stroke={strokeColor}
|
||||
>
|
||||
<polygon points="10 0, 0 3.5, 10 7" fill={strokeColor} />
|
||||
</marker>
|
||||
<marker
|
||||
id={CONNECTION_HEAD_ID_END}
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="10"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
stroke={strokeColor}
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill={strokeColor} />
|
||||
</marker>
|
||||
</defs>
|
||||
{vertices?.length ? (
|
||||
// Render path with vertices
|
||||
<g>
|
||||
{/* heighlight line */}
|
||||
<path
|
||||
id={`${CONNECTION_LINE_ID}_transparent`}
|
||||
d={pathString}
|
||||
cursor={connectionCursorStyle}
|
||||
pointerEvents="auto"
|
||||
stroke="transparent"
|
||||
strokeWidth={15}
|
||||
fill={'none'}
|
||||
style={isSelected ? selectedStyles : {}}
|
||||
/>
|
||||
{/* real line */}
|
||||
<path
|
||||
d={pathString}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={lineStyle}
|
||||
strokeDashoffset={1}
|
||||
fill={'none'}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
>
|
||||
{shouldAnimate && (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values={getAnimationDirection()}
|
||||
dur="5s"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
fill={'freeze'}
|
||||
/>
|
||||
)}
|
||||
</path>
|
||||
{isSelected && (
|
||||
<g>
|
||||
{/* vertices */}
|
||||
{vertices.map((value, index) => {
|
||||
return (
|
||||
<circle
|
||||
id={CONNECTION_VERTEX_ID}
|
||||
data-index={index}
|
||||
key={`${CONNECTION_VERTEX_ID}${index}_${idx}`}
|
||||
cx={value.x * xDist + xStart}
|
||||
cy={value.y * yDist + yStart}
|
||||
r={5}
|
||||
stroke={strokeColor}
|
||||
className={styles.vertex}
|
||||
cursor={'crosshair'}
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* midpoints */}
|
||||
{vertices.length < maximumVertices &&
|
||||
addVertices.map((value, index) => {
|
||||
return (
|
||||
<circle
|
||||
id={CONNECTION_VERTEX_ADD_ID}
|
||||
data-index={index}
|
||||
key={`${CONNECTION_VERTEX_ADD_ID}${index}_${idx}`}
|
||||
cx={value.x * xDist + xStart}
|
||||
cy={value.y * yDist + yStart}
|
||||
r={4}
|
||||
stroke={strokeColor}
|
||||
className={styles.addVertex}
|
||||
cursor={'crosshair'}
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
) : (
|
||||
// Render line without vertices
|
||||
<g>
|
||||
{/* heighlight line */}
|
||||
<line
|
||||
id={`${CONNECTION_LINE_ID}_transparent`}
|
||||
cursor={connectionCursorStyle}
|
||||
pointerEvents="auto"
|
||||
stroke="transparent"
|
||||
strokeWidth={15}
|
||||
style={isSelected ? selectedStyles : {}}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
/>
|
||||
{/* real line */}
|
||||
<line
|
||||
id={CONNECTION_LINE_ID}
|
||||
stroke={strokeColor}
|
||||
pointerEvents="auto"
|
||||
strokeWidth={strokeWidth}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
strokeDasharray={lineStyle}
|
||||
strokeDashoffset={1}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
cursor={connectionCursorStyle}
|
||||
>
|
||||
{shouldAnimate && (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values={getAnimationDirection()}
|
||||
dur="5s"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
fill={'freeze'}
|
||||
/>
|
||||
)}
|
||||
</line>
|
||||
{/* initial midpoint */}
|
||||
{isSelected && (
|
||||
<circle
|
||||
id={CONNECTION_VERTEX_ADD_ID}
|
||||
data-index={0}
|
||||
cx={midpoint.x}
|
||||
cy={midpoint.y}
|
||||
r={4}
|
||||
stroke={strokeColor}
|
||||
className={styles.addVertex}
|
||||
cursor={'crosshair'}
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg ref={setConnectionsSVGRef} className={styles.connection}>
|
||||
<defs>
|
||||
<marker
|
||||
id={EDITOR_HEAD_ID}
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="10"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
stroke={defaultArrowColor}
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill={defaultArrowColor} />
|
||||
</marker>
|
||||
</defs>
|
||||
{/* svg line for connection creation */}
|
||||
<line
|
||||
ref={setLineRef}
|
||||
stroke={defaultArrowColor}
|
||||
strokeWidth={2}
|
||||
markerEnd={`url(#${EDITOR_HEAD_ID})`}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* svg circle for initial vertex?
|
||||
path? is it for the line drag handling? */}
|
||||
<path
|
||||
ref={setVertexPathRef}
|
||||
stroke={defaultArrowColor}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={'5, 5'}
|
||||
fill={'none'}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<circle
|
||||
ref={setVertexRef}
|
||||
stroke={defaultArrowColor}
|
||||
r={4}
|
||||
className={styles.vertex}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{renderConnections()}
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
connection: css({
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
vertex: css({
|
||||
fill: '#44aaff',
|
||||
strokeWidth: 2,
|
||||
}),
|
||||
addVertex: css({
|
||||
fill: '#44aaff',
|
||||
opacity: 0.5,
|
||||
strokeWidth: 1,
|
||||
}),
|
||||
});
|
||||
@@ -43,6 +43,8 @@ export class Connections {
|
||||
connectionVertex?: SVGCircleElement;
|
||||
connectionSource?: ElementState;
|
||||
connectionTarget?: ElementState;
|
||||
// for back compatibility with Connections2
|
||||
connectionsSVG?: SVGElement;
|
||||
isDrawingConnection?: boolean;
|
||||
selectedVertexIndex?: number;
|
||||
didConnectionLeaveHighlight?: boolean;
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
import * as React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CanvasConnection, ConnectionCoordinates, ConnectionPath } from 'app/features/canvas/element';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { findElementByTarget } from 'app/features/canvas/runtime/sceneElementManagement';
|
||||
|
||||
import { ConnectionState } from '../../types';
|
||||
import {
|
||||
calculateAngle,
|
||||
calculateCoordinates2,
|
||||
getConnections,
|
||||
getElementTransformAndDimensions,
|
||||
getNormalizedRotatedOffset,
|
||||
getParentBoundingClientRect,
|
||||
isConnectionSource,
|
||||
isConnectionTarget,
|
||||
} from '../../utils';
|
||||
|
||||
import {
|
||||
CONNECTION_ANCHOR_ALT,
|
||||
CONNECTION_ANCHOR_HIGHLIGHT_OFFSET,
|
||||
ANCHORS,
|
||||
ANCHOR_PADDING,
|
||||
HALF_SIZE,
|
||||
} from './ConnectionAnchors';
|
||||
import { ConnectionAnchors } from './ConnectionAnchors2';
|
||||
import { ConnectionSVG } from './ConnectionSVG2';
|
||||
|
||||
export const CONNECTION_VERTEX_ID = 'vertex';
|
||||
export const CONNECTION_VERTEX_ADD_ID = 'vertexAdd';
|
||||
const CONNECTION_VERTEX_ORTHO_TOLERANCE = 0.05; // Cartesian ratio against vertical or horizontal tolerance
|
||||
const CONNECTION_VERTEX_SNAP_TOLERANCE = (5 / 180) * Math.PI; // Multi-segment snapping angle in radians to trigger vertex removal
|
||||
|
||||
export class Connections2 {
|
||||
scene: Scene;
|
||||
connectionAnchorDiv?: HTMLDivElement;
|
||||
anchorsDiv?: HTMLDivElement;
|
||||
connectionLine?: SVGLineElement;
|
||||
connectionVertexPath?: SVGPathElement;
|
||||
connectionVertex?: SVGCircleElement;
|
||||
connectionsSVG?: SVGElement;
|
||||
connectionSource?: ElementState;
|
||||
connectionTarget?: ElementState;
|
||||
isDrawingConnection?: boolean;
|
||||
selectedVertexIndex?: number;
|
||||
didConnectionLeaveHighlight?: boolean;
|
||||
state: ConnectionState[] = [];
|
||||
readonly selection = new BehaviorSubject<ConnectionState | undefined>(undefined);
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.scene = scene;
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
select = (connection: ConnectionState | undefined) => {
|
||||
if (connection === this.selection.value) {
|
||||
return;
|
||||
}
|
||||
this.selection.next(connection);
|
||||
};
|
||||
|
||||
updateState = () => {
|
||||
this.state = getConnections(this.scene.byName);
|
||||
|
||||
const s = this.selection.value;
|
||||
if (s) {
|
||||
for (let c of this.state) {
|
||||
if (c.source === s.source && c.index === s.index) {
|
||||
this.selection.next(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setConnectionAnchorRef = (anchorElement: HTMLDivElement) => {
|
||||
this.connectionAnchorDiv = anchorElement;
|
||||
};
|
||||
|
||||
setAnchorsRef = (anchorsElement: HTMLDivElement) => {
|
||||
this.anchorsDiv = anchorsElement;
|
||||
};
|
||||
|
||||
setConnectionsSVGRef = (connectionsSVG: SVGElement) => {
|
||||
this.connectionsSVG = connectionsSVG;
|
||||
};
|
||||
|
||||
setConnectionLineRef = (connectionLine: SVGLineElement) => {
|
||||
this.connectionLine = connectionLine;
|
||||
};
|
||||
|
||||
setConnectionVertexRef = (connectionVertex: SVGCircleElement) => {
|
||||
this.connectionVertex = connectionVertex;
|
||||
};
|
||||
|
||||
setConnectionVertexPathRef = (connectionVertexPath: SVGPathElement) => {
|
||||
this.connectionVertexPath = connectionVertexPath;
|
||||
};
|
||||
|
||||
// Recursively find the first parent that is a canvas element
|
||||
findElementTarget = (element: Element): ElementState | undefined => {
|
||||
let elementTarget = undefined;
|
||||
|
||||
// Cap recursion at the scene level
|
||||
if (element === this.scene.viewportDiv) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
elementTarget = findElementByTarget(element, this.scene.root.elements);
|
||||
|
||||
if (!elementTarget && element.parentElement) {
|
||||
elementTarget = this.findElementTarget(element.parentElement);
|
||||
}
|
||||
|
||||
return elementTarget;
|
||||
};
|
||||
|
||||
handleMouseEnter = (event: React.MouseEvent) => {
|
||||
if (!(event.target instanceof Element) || !this.scene.isEditingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let element: ElementState | undefined = this.findElementTarget(event.target);
|
||||
|
||||
if (!element) {
|
||||
console.log('no element');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isDrawingConnection) {
|
||||
this.connectionTarget = element;
|
||||
} else {
|
||||
this.connectionSource = element;
|
||||
if (!this.connectionSource) {
|
||||
console.log('no connection source');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const customElementAnchors = element?.item.customConnectionAnchors || ANCHORS;
|
||||
// This type cast is necessary as TS doesn't understand that `Element` is an `HTMLElement`
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const anchors = Array.from(this.anchorsDiv?.children as HTMLCollectionOf<HTMLElement>);
|
||||
const anchorsAmount = customElementAnchors.length;
|
||||
|
||||
// re-calculate the position of the existing anchors on hover
|
||||
// and hide the rest of the anchors if there are more than the custom ones
|
||||
anchors.forEach((anchor, index) => {
|
||||
if (index >= anchorsAmount) {
|
||||
anchor.style.display = 'none';
|
||||
} else {
|
||||
const { x, y } = customElementAnchors[index];
|
||||
anchor.style.top = `calc(${-y * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`;
|
||||
anchor.style.left = `calc(${x * 50 + 50}% - ${HALF_SIZE}px - ${ANCHOR_PADDING}px)`;
|
||||
anchor.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
const { top, left, width, height, rotation } = getElementTransformAndDimensions(element.div!);
|
||||
|
||||
if (this.connectionAnchorDiv) {
|
||||
this.connectionAnchorDiv.style.display = 'none';
|
||||
this.connectionAnchorDiv.style.display = 'block';
|
||||
this.connectionAnchorDiv.style.transform = `translate(${left}px, ${top}px) rotate(${rotation}deg)`;
|
||||
this.connectionAnchorDiv.style.height = `${height}px`;
|
||||
this.connectionAnchorDiv.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// Return boolean indicates if connection anchors were hidden or not
|
||||
handleMouseLeave = (event: React.MouseEvent | React.FocusEvent): boolean => {
|
||||
// If mouse is leaving INTO the anchor image, don't remove div
|
||||
if (
|
||||
event.relatedTarget instanceof HTMLImageElement &&
|
||||
event.relatedTarget.getAttribute('alt') === CONNECTION_ANCHOR_ALT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.connectionTarget = undefined;
|
||||
this.connectionAnchorDiv!.style.display = 'none';
|
||||
return true;
|
||||
};
|
||||
|
||||
connectionListener = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!(this.connectionLine && this.scene.viewportDiv && this.scene.viewportDiv.parentElement)) {
|
||||
return;
|
||||
}
|
||||
const { scale } = this.scene;
|
||||
const parentBoundingRect = getParentBoundingClientRect(this.scene);
|
||||
|
||||
if (!parentBoundingRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = (event.pageX - parentBoundingRect.x) / scale;
|
||||
const y = (event.pageY - parentBoundingRect.y) / scale;
|
||||
|
||||
this.connectionLine.setAttribute('x2', `${x}`);
|
||||
this.connectionLine.setAttribute('y2', `${y}`);
|
||||
|
||||
const connectionLineX1 = this.connectionLine.x1.baseVal.value;
|
||||
const connectionLineY1 = this.connectionLine.y1.baseVal.value;
|
||||
|
||||
if (!this.didConnectionLeaveHighlight) {
|
||||
const connectionLength = Math.hypot(x - connectionLineX1, y - connectionLineY1);
|
||||
if (connectionLength > CONNECTION_ANCHOR_HIGHLIGHT_OFFSET) {
|
||||
this.didConnectionLeaveHighlight = true;
|
||||
this.connectionLine.style.display = 'block';
|
||||
this.isDrawingConnection = true;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// SAVING CONNECTION
|
||||
//
|
||||
if (!event.buttons) {
|
||||
if (this.connectionSource && this.connectionSource.div && this.connectionSource.div.parentElement) {
|
||||
const { x: sourceX, y: sourceY } = getNormalizedRotatedOffset(
|
||||
this.connectionSource.div,
|
||||
connectionLineX1,
|
||||
connectionLineY1
|
||||
);
|
||||
|
||||
let targetX;
|
||||
let targetY;
|
||||
let targetName;
|
||||
|
||||
if (this.connectionTarget && this.connectionTarget.div) {
|
||||
({ x: targetX, y: targetY } = getNormalizedRotatedOffset(this.connectionTarget.div, x, y));
|
||||
targetName = this.connectionTarget.options.name;
|
||||
} else {
|
||||
// if there is no target (open connection)
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
source: {
|
||||
x: sourceX,
|
||||
y: sourceY,
|
||||
},
|
||||
target: {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
},
|
||||
targetName: targetName,
|
||||
color: {
|
||||
fixed: config.theme2.colors.text.primary,
|
||||
},
|
||||
size: {
|
||||
fixed: 2,
|
||||
min: 1,
|
||||
max: 10,
|
||||
},
|
||||
path: ConnectionPath.Straight,
|
||||
};
|
||||
|
||||
const { options } = this.connectionSource;
|
||||
if (!options.connections) {
|
||||
options.connections = [];
|
||||
}
|
||||
if (this.didConnectionLeaveHighlight) {
|
||||
this.connectionSource.options.connections = [...options.connections, connection];
|
||||
this.connectionSource.onChange(this.connectionSource.options);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.connectionLine) {
|
||||
this.connectionLine.style.display = 'none';
|
||||
}
|
||||
|
||||
if (this.scene.selecto && this.scene.selecto.rootContainer) {
|
||||
this.scene.selecto.rootContainer.style.cursor = 'default';
|
||||
this.scene.selecto.rootContainer.removeEventListener('mousemove', this.connectionListener);
|
||||
}
|
||||
|
||||
this.isDrawingConnection = false;
|
||||
this.updateState();
|
||||
this.scene.save();
|
||||
}
|
||||
};
|
||||
|
||||
// Handles mousemove and mouseup events when dragging an existing vertex
|
||||
vertexListener = (event: MouseEvent) => {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (!(this.connectionVertex && this.scene.viewportDiv && this.scene.viewportDiv.parentElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentBoundingRect = getParentBoundingClientRect(this.scene);
|
||||
|
||||
if (!parentBoundingRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scale } = this.scene;
|
||||
const x = (event.pageX - parentBoundingRect.x) / scale;
|
||||
const y = (event.pageY - parentBoundingRect.y) / scale;
|
||||
|
||||
this.connectionVertex?.setAttribute('cx', `${x}`);
|
||||
this.connectionVertex?.setAttribute('cy', `${y}`);
|
||||
|
||||
const selectedValue = this.selection.value;
|
||||
|
||||
const { x1, y1, x2, y2 } = calculateCoordinates2(
|
||||
selectedValue!.source,
|
||||
selectedValue!.target,
|
||||
selectedValue?.info!
|
||||
);
|
||||
|
||||
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
|
||||
if (selectedValue?.sourceOriginal && selectedValue.targetOriginal) {
|
||||
xStart = selectedValue.sourceOriginal.x;
|
||||
yStart = selectedValue.sourceOriginal.y;
|
||||
xEnd = selectedValue.targetOriginal.x;
|
||||
yEnd = selectedValue.targetOriginal.y;
|
||||
}
|
||||
|
||||
const xDist = xEnd - xStart;
|
||||
const yDist = yEnd - yStart;
|
||||
|
||||
let vx1 = x1;
|
||||
let vy1 = y1;
|
||||
let vx2 = x2;
|
||||
let vy2 = y2;
|
||||
if (selectedValue && selectedValue.vertices) {
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) {
|
||||
vx1 = selectedValue.vertices[this.selectedVertexIndex - 1].x * xDist + xStart;
|
||||
vy1 = selectedValue.vertices[this.selectedVertexIndex - 1].y * yDist + yStart;
|
||||
}
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < selectedValue.vertices.length - 1) {
|
||||
vx2 = selectedValue.vertices[this.selectedVertexIndex + 1].x * xDist + xStart;
|
||||
vy2 = selectedValue.vertices[this.selectedVertexIndex + 1].y * yDist + yStart;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if slope before vertex and after vertex is within snapping tolerance
|
||||
let xSnap = x;
|
||||
let ySnap = y;
|
||||
let deleteVertex = false;
|
||||
// Ignore if control key being held
|
||||
if (!event.ctrlKey) {
|
||||
// Check if segment before and after vertex are close to vertical or horizontal
|
||||
const verticalBefore = Math.abs((xSnap - vx1) / (ySnap - vy1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
const verticalAfter = Math.abs((xSnap - vx2) / (ySnap - vy2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
const horizontalBefore = Math.abs((ySnap - vy1) / (xSnap - vx1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
const horizontalAfter = Math.abs((ySnap - vy2) / (xSnap - vx2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
|
||||
if (verticalBefore) {
|
||||
xSnap = vx1;
|
||||
} else if (verticalAfter) {
|
||||
xSnap = vx2;
|
||||
}
|
||||
if (horizontalBefore) {
|
||||
ySnap = vy1;
|
||||
} else if (horizontalAfter) {
|
||||
ySnap = vy2;
|
||||
}
|
||||
|
||||
if ((verticalBefore || verticalAfter) && (horizontalBefore || horizontalAfter)) {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'move';
|
||||
} else if (verticalBefore || verticalAfter) {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'col-resize';
|
||||
} else if (horizontalBefore || horizontalAfter) {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'row-resize';
|
||||
}
|
||||
|
||||
const angleOverall = calculateAngle(vx1, vy1, vx2, vy2);
|
||||
const angleBefore = calculateAngle(vx1, vy1, x, y);
|
||||
deleteVertex = Math.abs(angleBefore - angleOverall) < CONNECTION_VERTEX_SNAP_TOLERANCE;
|
||||
}
|
||||
|
||||
if (deleteVertex) {
|
||||
// Display temporary vertex removal
|
||||
this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${vx2} ${vy2}`);
|
||||
this.connectionVertexPath!.style.display = 'block';
|
||||
this.connectionVertex.style.display = 'none';
|
||||
} else {
|
||||
// Display temporary vertex during drag
|
||||
this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${xSnap} ${ySnap} L${vx2} ${vy2}`);
|
||||
this.connectionVertexPath!.style.display = 'block';
|
||||
this.connectionVertex.style.display = 'block';
|
||||
}
|
||||
|
||||
// Handle mouseup
|
||||
if (!event.buttons) {
|
||||
// Remove existing event listener
|
||||
this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexListener);
|
||||
this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexListener);
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'auto';
|
||||
this.connectionVertexPath!.style.display = 'none';
|
||||
this.connectionVertex.style.display = 'none';
|
||||
|
||||
// call onChange here and update appropriate index of connection vertices array
|
||||
const connectionIndex = selectedValue?.index;
|
||||
const vertexIndex = this.selectedVertexIndex;
|
||||
|
||||
if (connectionIndex !== undefined && vertexIndex !== undefined) {
|
||||
const currentSource = selectedValue!.source;
|
||||
if (currentSource.options.connections) {
|
||||
const currentConnections = [...currentSource.options.connections];
|
||||
if (currentConnections[connectionIndex].vertices) {
|
||||
const currentVertices = [...currentConnections[connectionIndex].vertices!];
|
||||
|
||||
// TODO for vertex removal, clear out originals?
|
||||
if (deleteVertex) {
|
||||
currentVertices.splice(vertexIndex, 1);
|
||||
} else {
|
||||
const currentVertex = { ...currentVertices[vertexIndex] };
|
||||
|
||||
currentVertex.x = (xSnap - xStart) / xDist;
|
||||
currentVertex.y = (ySnap - yStart) / yDist;
|
||||
|
||||
currentVertices[vertexIndex] = currentVertex;
|
||||
}
|
||||
|
||||
currentConnections[connectionIndex] = {
|
||||
...currentConnections[connectionIndex],
|
||||
vertices: currentVertices,
|
||||
};
|
||||
|
||||
// Update save model
|
||||
currentSource.onChange({ ...currentSource.options, connections: currentConnections });
|
||||
this.updateState();
|
||||
this.scene.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handles mousemove and mouseup events when dragging a new vertex
|
||||
vertexAddListener = (event: MouseEvent) => {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (!(this.connectionVertex && this.scene.viewportDiv && this.scene.viewportDiv.parentElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentBoundingRect = getParentBoundingClientRect(this.scene);
|
||||
|
||||
if (!parentBoundingRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scale } = this.scene;
|
||||
const x = (event.pageX - parentBoundingRect.x) / scale;
|
||||
const y = (event.pageY - parentBoundingRect.y) / scale;
|
||||
|
||||
this.connectionVertex?.setAttribute('cx', `${x}`);
|
||||
this.connectionVertex?.setAttribute('cy', `${y}`);
|
||||
|
||||
const selectedValue = this.selection.value;
|
||||
|
||||
const { x1, y1, x2, y2 } = calculateCoordinates2(
|
||||
selectedValue!.source,
|
||||
selectedValue!.target,
|
||||
selectedValue?.info!
|
||||
);
|
||||
|
||||
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
|
||||
if (selectedValue?.sourceOriginal && selectedValue.targetOriginal) {
|
||||
xStart = selectedValue.sourceOriginal.x;
|
||||
yStart = selectedValue.sourceOriginal.y;
|
||||
xEnd = selectedValue.targetOriginal.x;
|
||||
yEnd = selectedValue.targetOriginal.y;
|
||||
}
|
||||
|
||||
const xDist = xEnd - xStart;
|
||||
const yDist = yEnd - yStart;
|
||||
|
||||
let vx1 = x1;
|
||||
let vy1 = y1;
|
||||
let vx2 = x2;
|
||||
let vy2 = y2;
|
||||
if (selectedValue && selectedValue.vertices) {
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) {
|
||||
vx1 = selectedValue.vertices[this.selectedVertexIndex - 1].x * xDist + xStart;
|
||||
vy1 = selectedValue.vertices[this.selectedVertexIndex - 1].y * yDist + yStart;
|
||||
}
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < selectedValue.vertices.length) {
|
||||
vx2 = selectedValue.vertices[this.selectedVertexIndex].x * xDist + xStart;
|
||||
vy2 = selectedValue.vertices[this.selectedVertexIndex].y * yDist + yStart;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if slope before vertex and after vertex is within snapping tolerance
|
||||
let xSnap = x;
|
||||
let ySnap = y;
|
||||
// Ignore if control key being held
|
||||
if (!event.ctrlKey) {
|
||||
// Check if segment before and after vertex are close to vertical or horizontal
|
||||
const verticalBefore = Math.abs((xSnap - vx1) / (ySnap - vy1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
const verticalAfter = Math.abs((xSnap - vx2) / (ySnap - vy2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
const horizontalBefore = Math.abs((ySnap - vy1) / (xSnap - vx1)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
const horizontalAfter = Math.abs((ySnap - vy2) / (xSnap - vx2)) < CONNECTION_VERTEX_ORTHO_TOLERANCE;
|
||||
|
||||
if (verticalBefore) {
|
||||
xSnap = vx1;
|
||||
} else if (verticalAfter) {
|
||||
xSnap = vx2;
|
||||
}
|
||||
if (horizontalBefore) {
|
||||
ySnap = vy1;
|
||||
} else if (horizontalAfter) {
|
||||
ySnap = vy2;
|
||||
}
|
||||
|
||||
if ((verticalBefore || verticalAfter) && (horizontalBefore || horizontalAfter)) {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'move';
|
||||
} else if (verticalBefore || verticalAfter) {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'col-resize';
|
||||
} else if (horizontalBefore || horizontalAfter) {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'row-resize';
|
||||
}
|
||||
}
|
||||
|
||||
this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${xSnap} ${ySnap} L${vx2} ${vy2}`);
|
||||
this.connectionVertexPath!.style.display = 'block';
|
||||
this.connectionVertex.style.display = 'block';
|
||||
|
||||
// Handle mouseup
|
||||
if (!event.buttons) {
|
||||
// Remove existing event listener
|
||||
this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexAddListener);
|
||||
this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexAddListener);
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'auto';
|
||||
this.connectionVertexPath!.style.display = 'none';
|
||||
this.connectionVertex.style.display = 'none';
|
||||
|
||||
// call onChange here and insert new vertex at appropriate index of connection vertices array
|
||||
const connectionIndex = selectedValue?.index;
|
||||
const vertexIndex = this.selectedVertexIndex;
|
||||
|
||||
if (connectionIndex !== undefined && vertexIndex !== undefined) {
|
||||
const currentSource = selectedValue!.source;
|
||||
if (currentSource.options.connections) {
|
||||
const currentConnections = [...currentSource.options.connections];
|
||||
// Calculate normalized coordinates for the new vertex, using rotatedX/Y
|
||||
const newVertex = { x: (xSnap - xStart) / xDist, y: (ySnap - yStart) / yDist };
|
||||
if (currentConnections[connectionIndex].vertices) {
|
||||
const currentVertices = [...currentConnections[connectionIndex].vertices!];
|
||||
currentVertices.splice(vertexIndex, 0, newVertex);
|
||||
currentConnections[connectionIndex] = {
|
||||
...currentConnections[connectionIndex],
|
||||
vertices: currentVertices,
|
||||
};
|
||||
} else {
|
||||
// For first vertex creation
|
||||
const currentVertices: ConnectionCoordinates[] = [newVertex];
|
||||
currentConnections[connectionIndex] = {
|
||||
...currentConnections[connectionIndex],
|
||||
vertices: currentVertices,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for original state
|
||||
if (
|
||||
!currentConnections[connectionIndex].sourceOriginal ||
|
||||
!currentConnections[connectionIndex].targetOriginal
|
||||
) {
|
||||
currentConnections[connectionIndex] = {
|
||||
...currentConnections[connectionIndex],
|
||||
sourceOriginal: { x: x1, y: y1 },
|
||||
targetOriginal: { x: x2, y: y2 },
|
||||
};
|
||||
}
|
||||
// Update save model
|
||||
currentSource.onChange({ ...currentSource.options, connections: currentConnections });
|
||||
this.updateState();
|
||||
this.scene.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleConnectionDragStart = (selectedTarget: HTMLElement, clientX: number, clientY: number) => {
|
||||
this.scene.selecto!.rootContainer!.style.cursor = 'crosshair';
|
||||
|
||||
if (this.connectionLine && this.scene.viewportDiv && this.scene.viewportDiv.parentElement) {
|
||||
const connectionStartTargetBox = selectedTarget.getBoundingClientRect();
|
||||
|
||||
const { scale } = this.scene;
|
||||
const parentBoundingRect = getParentBoundingClientRect(this.scene);
|
||||
|
||||
if (!parentBoundingRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiply by transform scale to calculate the correct scaled offset
|
||||
const connectionAnchorOffsetX = CONNECTION_ANCHOR_HIGHLIGHT_OFFSET * scale;
|
||||
const connectionAnchorOffsetY = CONNECTION_ANCHOR_HIGHLIGHT_OFFSET * scale;
|
||||
|
||||
const x = (connectionStartTargetBox.x - parentBoundingRect.x + connectionAnchorOffsetX) / scale;
|
||||
const y = (connectionStartTargetBox.y - parentBoundingRect.y + connectionAnchorOffsetY) / scale;
|
||||
|
||||
const mouseX = clientX - parentBoundingRect.x;
|
||||
const mouseY = clientY - parentBoundingRect.y;
|
||||
|
||||
this.connectionLine.setAttribute('x1', `${x}`);
|
||||
this.connectionLine.setAttribute('y1', `${y}`);
|
||||
this.connectionLine.setAttribute('x2', `${mouseX}`);
|
||||
this.connectionLine.setAttribute('y2', `${mouseY}`);
|
||||
this.didConnectionLeaveHighlight = false;
|
||||
}
|
||||
|
||||
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener);
|
||||
};
|
||||
|
||||
// Add event listener at root container during existing vertex drag
|
||||
handleVertexDragStart = (selectedTarget: HTMLElement) => {
|
||||
// Get vertex index from selected target data
|
||||
this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index'));
|
||||
|
||||
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexListener);
|
||||
this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexListener);
|
||||
};
|
||||
|
||||
// Add event listener at root container during creation of new vertex
|
||||
handleVertexAddDragStart = (selectedTarget: HTMLElement) => {
|
||||
// Get vertex index from selected target data
|
||||
this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index'));
|
||||
|
||||
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexAddListener);
|
||||
this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexAddListener);
|
||||
};
|
||||
|
||||
onChange = (current: ConnectionState, update: CanvasConnection) => {
|
||||
const connections = current.source.options.connections?.splice(0) ?? [];
|
||||
connections[current.index] = update;
|
||||
current.source.onChange({ ...current.source.options, connections });
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
// used for moveable actions
|
||||
connectionsNeedUpdate = (element: ElementState): boolean => {
|
||||
return isConnectionSource(element) || isConnectionTarget(element, this.scene.byName);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<ConnectionAnchors
|
||||
setRef={this.setConnectionAnchorRef}
|
||||
setAnchorsRef={this.setAnchorsRef}
|
||||
handleMouseLeave={this.handleMouseLeave}
|
||||
/>
|
||||
<ConnectionSVG
|
||||
setLineRef={this.setConnectionLineRef}
|
||||
setVertexPathRef={this.setConnectionVertexPathRef}
|
||||
setVertexRef={this.setConnectionVertexRef}
|
||||
setConnectionsSVGRef={this.setConnectionsSVGRef}
|
||||
scene={this.scene}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function PlacementEditor({ item }: Props) {
|
||||
|
||||
const onHorizontalConstraintChange = (h: HorizontalConstraint) => {
|
||||
element.options.constraint!.horizontal = h;
|
||||
element.setPlacementFromConstraint();
|
||||
element.setPlacementFromConstraint(undefined, undefined, settings.scene.scale);
|
||||
settings.scene.revId++;
|
||||
settings.scene.save(true);
|
||||
reselectElementAfterChange();
|
||||
@@ -89,7 +89,7 @@ export function PlacementEditor({ item }: Props) {
|
||||
|
||||
const onVerticalConstraintChange = (v: VerticalConstraint) => {
|
||||
element.options.constraint!.vertical = v;
|
||||
element.setPlacementFromConstraint();
|
||||
element.setPlacementFromConstraint(undefined, undefined, settings.scene.scale);
|
||||
settings.scene.revId++;
|
||||
settings.scene.save(true);
|
||||
reselectElementAfterChange();
|
||||
|
||||
@@ -36,7 +36,7 @@ export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilde
|
||||
category,
|
||||
description: t('canvas.description-pan-zoom', 'Enable pan and zoom'),
|
||||
defaultValue: false,
|
||||
showIf: (opts) => config.featureToggles.canvasPanelPanZoom,
|
||||
showIf: () => config.featureToggles.canvasPanelPanZoom,
|
||||
});
|
||||
builder.addCustomEditor({
|
||||
id: 'panZoomHelp',
|
||||
@@ -47,15 +47,11 @@ export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilde
|
||||
showIf: (opts) => config.featureToggles.canvasPanelPanZoom && opts.panZoom,
|
||||
});
|
||||
builder.addBooleanSwitch({
|
||||
path: 'infinitePan',
|
||||
name: t('canvas.name-infinite-panning', 'Infinite panning'),
|
||||
category,
|
||||
description: t(
|
||||
'canvas.description-infinite-panning',
|
||||
'Enable infinite panning - useful for expansive canvases. Warning: this is an experimental feature and currently only works well with elements that are top / left constrained'
|
||||
),
|
||||
path: 'zoomToContent',
|
||||
name: 'Zoom to content',
|
||||
description: 'Automatically zoom to fit content',
|
||||
defaultValue: false,
|
||||
showIf: (opts) => config.featureToggles.canvasPanelPanZoom && opts.panZoom,
|
||||
showIf: () => config.featureToggles.canvasPanelPanZoom,
|
||||
});
|
||||
|
||||
category = [t('canvas.category-tooltip', 'Tooltip')];
|
||||
|
||||
@@ -99,8 +99,8 @@ composableKinds: PanelCfg: {
|
||||
showAdvancedTypes: bool | *true
|
||||
// Enable pan and zoom
|
||||
panZoom: bool | *true
|
||||
// Enable infinite pan
|
||||
infinitePan: bool | *true
|
||||
// Zoom to content
|
||||
zoomToContent: bool | *true
|
||||
// The root element of canvas (frame), where all canvas elements are nested
|
||||
// TODO: Figure out how to define a default value for this
|
||||
root: {
|
||||
|
||||
@@ -115,10 +115,6 @@ export interface CanvasTooltip {
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Enable infinite pan
|
||||
*/
|
||||
infinitePan: boolean;
|
||||
/**
|
||||
* Enable inline editing
|
||||
*/
|
||||
@@ -153,11 +149,15 @@ export interface Options {
|
||||
* Controls tooltip options
|
||||
*/
|
||||
tooltip: CanvasTooltip;
|
||||
/**
|
||||
* Zoom to content
|
||||
*/
|
||||
zoomToContent: boolean;
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
infinitePan: true,
|
||||
inlineEditing: true,
|
||||
panZoom: true,
|
||||
showAdvancedTypes: true,
|
||||
zoomToContent: true,
|
||||
};
|
||||
|
||||
@@ -204,6 +204,100 @@ export const calculateCoordinates = (
|
||||
return { x1, y1, x2, y2 };
|
||||
};
|
||||
|
||||
export const calculateCoordinates2 = (source: ElementState, target: ElementState, info: CanvasConnection) => {
|
||||
const { x: x1, y: y1 } = getRotatedConnectionPoint(source.div!, info.source.x, info.source.y);
|
||||
|
||||
let x2 = 0;
|
||||
let y2 = 0;
|
||||
const targetDiv = target.div;
|
||||
if (info.targetName && targetDiv) {
|
||||
({ x: x2, y: y2 } = getRotatedConnectionPoint(targetDiv, info.target.x, info.target.y));
|
||||
} else {
|
||||
x2 = info.target.x;
|
||||
y2 = info.target.y;
|
||||
}
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
};
|
||||
|
||||
export const getElementTransformAndDimensions = (element: Element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
|
||||
const transform = style.transform;
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let rotation = 0;
|
||||
|
||||
if (transform !== 'none') {
|
||||
// Use DOMMatrix to parse the transform string
|
||||
const matrix = new DOMMatrix(transform);
|
||||
|
||||
// Extract x and y values
|
||||
x = matrix.m41;
|
||||
y = matrix.m42;
|
||||
|
||||
// Extract rotation in radians and convert to degrees
|
||||
// For 2D transforms, rotation = atan2(m21, m11)
|
||||
rotation = -Math.atan2(matrix.m21, matrix.m11) * (180 / Math.PI);
|
||||
}
|
||||
|
||||
// Get the width and height of the element
|
||||
// TODO: there sould be a better way than parseFloat
|
||||
const width = parseFloat(style.width);
|
||||
const height = parseFloat(style.height);
|
||||
|
||||
return { left: x, top: y, width, height, x, y, rotation };
|
||||
};
|
||||
|
||||
export const getNormalizedRotatedOffset = (div: HTMLDivElement, x: number, y: number) => {
|
||||
const { left, top, width, height, rotation } = getElementTransformAndDimensions(div);
|
||||
// Calculate center of source element
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
|
||||
// Calculate the offset from the center to the connection start point
|
||||
let dx = x - centerX;
|
||||
let dy = y - centerY;
|
||||
|
||||
// Adjust for rotation
|
||||
const rad = rotation * (Math.PI / 180);
|
||||
const cos = Math.cos(-rad);
|
||||
const sin = Math.sin(-rad);
|
||||
// Rotate the delta by the negative of the element's rotation
|
||||
const rotatedDx = dx * cos - dy * sin;
|
||||
const rotatedDy = dx * sin + dy * cos;
|
||||
|
||||
// Convert to normalized coordinates
|
||||
const normalizedX = rotatedDx / (width / 2);
|
||||
const normalizedY = -rotatedDy / (height / 2);
|
||||
|
||||
return { x: normalizedX, y: normalizedY };
|
||||
};
|
||||
|
||||
export const getRotatedConnectionPoint = (div: HTMLDivElement, normalizedX: number, normalizedY: number) => {
|
||||
const { left, top, width, height, rotation } = getElementTransformAndDimensions(div);
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
|
||||
// Calculate offset from center before rotation
|
||||
const offsetX = (normalizedX * width) / 2;
|
||||
const offsetY = -(normalizedY * height) / 2;
|
||||
|
||||
// Convert rotation to radians
|
||||
const rad = rotation * (Math.PI / 180);
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Apply rotation to offset
|
||||
const rotatedOffsetX = offsetX * cos - offsetY * sin;
|
||||
const rotatedOffsetY = offsetX * sin + offsetY * cos;
|
||||
|
||||
const x = centerX + rotatedOffsetX;
|
||||
const y = centerY + rotatedOffsetY;
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const calculateMidpoint = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
||||
};
|
||||
@@ -273,27 +367,12 @@ const getLineStyle = (lineStyle?: LineStyle) => {
|
||||
|
||||
export const getParentBoundingClientRect = (scene: Scene) => {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
const transformRef = scene.transformComponentRef?.current;
|
||||
return transformRef?.instance.contentComponent?.getBoundingClientRect();
|
||||
return scene.viewportDiv?.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return scene.div?.getBoundingClientRect();
|
||||
};
|
||||
|
||||
export const getTransformInstance = (scene: Scene) => {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
return scene.transformComponentRef?.current?.instance;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getParent = (scene: Scene) => {
|
||||
if (config.featureToggles.canvasPanelPanZoom) {
|
||||
return scene.transformComponentRef?.current?.instance.contentComponent;
|
||||
}
|
||||
return scene.div;
|
||||
};
|
||||
|
||||
export function getElementFields(frames: DataFrame[], opts: CanvasElementOptions) {
|
||||
const fields = new Set<Field>();
|
||||
const cfg = opts.config ?? {};
|
||||
@@ -327,3 +406,15 @@ export function getElementFields(frames: DataFrame[], opts: CanvasElementOptions
|
||||
|
||||
return [...fields];
|
||||
}
|
||||
|
||||
export function applyStyles(styles: React.CSSProperties, target: HTMLDivElement) {
|
||||
// INFO: CSSProperties can't be applied using setProperty, so we use Object.assign
|
||||
Object.assign(target.style, styles);
|
||||
}
|
||||
|
||||
export function removeStyles(styles: React.CSSProperties, target: HTMLDivElement) {
|
||||
for (const key in styles) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
target.style[key as any] = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3758,7 +3758,6 @@
|
||||
}
|
||||
},
|
||||
"description-experimental-types": "Enable selection of experimental element types",
|
||||
"description-infinite-panning": "Enable infinite panning - useful for expansive canvases. Warning: this is an experimental feature and currently only works well with elements that are top / left constrained",
|
||||
"description-inline-editing": "Enable editing the panel directly",
|
||||
"description-pan-zoom": "Enable pan and zoom",
|
||||
"direction-options": {
|
||||
@@ -3859,7 +3858,6 @@
|
||||
"name-align-text": "Align text",
|
||||
"name-color": "Text color",
|
||||
"name-experimental-types": "Experimental element types",
|
||||
"name-infinite-panning": "Infinite panning",
|
||||
"name-inline-editing": "Inline editing",
|
||||
"name-pan-zoom": "Pan and zoom",
|
||||
"name-text": "Text",
|
||||
|
||||
@@ -17645,7 +17645,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gesto@npm:^1.19.3, gesto@npm:^1.19.4":
|
||||
"gesto@npm:^1.19.1, gesto@npm:^1.19.3, gesto@npm:^1.19.4":
|
||||
version: 1.19.4
|
||||
resolution: "gesto@npm:1.19.4"
|
||||
dependencies:
|
||||
@@ -18409,6 +18409,7 @@ __metadata:
|
||||
i18next-pseudo: "npm:^2.2.1"
|
||||
immer: "npm:10.1.1"
|
||||
immutable: "npm:5.1.3"
|
||||
infinite-viewer: "npm:^0.29.1"
|
||||
ini: "npm:^5.0.0"
|
||||
ix: "npm:^7.0.0"
|
||||
jest: "npm:29.7.0"
|
||||
@@ -18497,7 +18498,6 @@ __metadata:
|
||||
react-virtualized-auto-sizer: "npm:1.0.26"
|
||||
react-window: "npm:1.8.11"
|
||||
react-window-infinite-loader: "npm:1.0.10"
|
||||
react-zoom-pan-pinch: "npm:^3.3.0"
|
||||
reduce-reducers: "npm:^1.0.4"
|
||||
redux: "npm:5.0.1"
|
||||
redux-mock-store: "npm:1.5.5"
|
||||
@@ -19519,6 +19519,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"infinite-viewer@npm:^0.29.1":
|
||||
version: 0.29.1
|
||||
resolution: "infinite-viewer@npm:0.29.1"
|
||||
dependencies:
|
||||
"@daybrush/utils": "npm:^1.13.0"
|
||||
"@egjs/agent": "npm:^2.2.1"
|
||||
"@scena/event-emitter": "npm:^1.0.5"
|
||||
css-styled: "npm:^1.0.8"
|
||||
framework-utils: "npm:^1.1.0"
|
||||
gesto: "npm:^1.19.1"
|
||||
checksum: 10/a3aefd8e3e3ee083e849495e8664cf2b2de4a9039cc3e4d07650ffdb404298b2f30c55b701965c490e4f75cf5d0dfcfa18abe83ff225a8845ebde7e7b91be117
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"inflight@npm:^1.0.4":
|
||||
version: 1.0.6
|
||||
resolution: "inflight@npm:1.0.6"
|
||||
@@ -27633,16 +27647,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-zoom-pan-pinch@npm:^3.3.0":
|
||||
version: 3.7.0
|
||||
resolution: "react-zoom-pan-pinch@npm:3.7.0"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-dom: "*"
|
||||
checksum: 10/5ae7f1ffea86fd19ae57f7b4c6818b282e13e00523200d23759db95a1518333044017583c2af3c179b2634c88c76162aa0ce8de79cd26359f07339415842ba34
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:18.3.1":
|
||||
version: 18.3.1
|
||||
resolution: "react@npm:18.3.1"
|
||||
|
||||
Reference in New Issue
Block a user