Add generic svg element
This commit is contained in:
@@ -128,6 +128,20 @@ The server element lets you easily represent a single server, a stack of servers
|
||||
|
||||
{{< figure src="/media/docs/grafana/canvas-server-element-9-4-0.png" max-width="650px" alt="Canvas server element" >}}
|
||||
|
||||
#### SVG
|
||||
|
||||
The SVG element lets you add custom SVG graphics to the canvas. You can enter raw SVG markup in the content field, and the element will render it with proper sanitization to prevent XSS attacks. This element is useful for creating custom icons, logos, or complex graphics that aren't available in the standard shape elements.
|
||||
|
||||
SVG element features:
|
||||
|
||||
- **Sanitized content**: All SVG content is automatically sanitized for security
|
||||
- **Data binding**: SVG content can be bound to field data using template variables
|
||||
- **Scalable**: SVG graphics scale cleanly at any size
|
||||
|
||||
The SVG element supports the following configuration options:
|
||||
|
||||
- **SVG Content**: Enter raw SVG markup. Content will be sanitized automatically.
|
||||
|
||||
#### Button
|
||||
|
||||
The button element lets you add a basic button to the canvas. Button elements support triggering basic, unauthenticated API calls. [API settings](#button-api-options) are found in the button element editor. You can also pass template variables in the API editor.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2, textUtil } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PositionDimensionMode, ScalarDimensionMode, TextDimensionConfig, TextDimensionMode } from '@grafana/schema';
|
||||
import { CodeEditor, useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
|
||||
import { CanvasElementItem, CanvasElementOptions, CanvasElementProps } from '../element';
|
||||
|
||||
export interface SvgConfig {
|
||||
content?: TextDimensionConfig;
|
||||
}
|
||||
|
||||
interface SvgData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function SvgDisplay(props: CanvasElementProps<SvgConfig, SvgData>) {
|
||||
const { data } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!data?.content) {
|
||||
return (
|
||||
<div className={styles.placeholder}>{t('canvas.svg-element.placeholder', 'Double click to add SVG content')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if content already has an SVG wrapper
|
||||
const hasSvgWrapper = data.content.trim().toLowerCase().startsWith('<svg');
|
||||
|
||||
let sanitizedContent: string;
|
||||
|
||||
if (hasSvgWrapper) {
|
||||
// Content already has SVG wrapper, sanitize as-is
|
||||
sanitizedContent = textUtil.sanitizeSVGContent(data.content);
|
||||
} else {
|
||||
// Content is a fragment - wrap in SVG before sanitizing
|
||||
const wrappedContent = `<svg width="100%" height="100%">${data.content}</svg>`;
|
||||
sanitizedContent = textUtil.sanitizeSVGContent(wrappedContent);
|
||||
}
|
||||
|
||||
return <div className={styles.container} dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
}),
|
||||
placeholder: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(1),
|
||||
border: `1px dashed ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
|
||||
export const svgItem: CanvasElementItem<SvgConfig, SvgData> = {
|
||||
id: 'svg',
|
||||
name: 'SVG',
|
||||
description: 'Generic SVG element with sanitized content',
|
||||
|
||||
display: SvgDisplay,
|
||||
|
||||
hasEditMode: false,
|
||||
|
||||
defaultSize: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
|
||||
getNewOptions: (options) => ({
|
||||
...options,
|
||||
config: {
|
||||
content: {
|
||||
mode: TextDimensionMode.Fixed,
|
||||
fixed: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="currentColor" /></svg>',
|
||||
},
|
||||
},
|
||||
background: {
|
||||
color: {
|
||||
fixed: 'transparent',
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
|
||||
rotation: options?.placement?.rotation ?? { fixed: 0, mode: ScalarDimensionMode.Clamped, min: 0, max: 360 },
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<SvgConfig>) => {
|
||||
const svgConfig = elementOptions.config;
|
||||
|
||||
const data: SvgData = {
|
||||
content: svgConfig?.content ? dimensionContext.getText(svgConfig.content).value() : '',
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
registerOptionsUI: (builder) => {
|
||||
const category = [t('canvas.svg-element.category', 'SVG')];
|
||||
|
||||
builder.addCustomEditor({
|
||||
category,
|
||||
id: 'svgContent',
|
||||
path: 'config.content',
|
||||
name: t('canvas.svg-element.content', 'SVG Content'),
|
||||
description: t('canvas.svg-element.content-description', 'Enter SVG content.'),
|
||||
editor: ({ value, onChange }) => {
|
||||
const currentValue = value?.fixed || '';
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
value={currentValue}
|
||||
language="xml"
|
||||
height="200px"
|
||||
onBlur={(newValue) => {
|
||||
onChange({
|
||||
...value,
|
||||
mode: TextDimensionMode.Fixed,
|
||||
fixed: newValue,
|
||||
});
|
||||
}}
|
||||
monacoOptions={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
folding: false,
|
||||
renderLineHighlight: 'none',
|
||||
overviewRulerBorder: false,
|
||||
hideCursorInOverviewRuler: true,
|
||||
overviewRulerLanes: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
settings: {},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { metricValueItem } from './elements/metricValue';
|
||||
import { parallelogramItem } from './elements/parallelogram';
|
||||
import { rectangleItem } from './elements/rectangle';
|
||||
import { serverItem } from './elements/server/server';
|
||||
import { svgItem } from './elements/svg';
|
||||
import { textItem } from './elements/text';
|
||||
import { triangleItem } from './elements/triangle';
|
||||
import { windTurbineItem } from './elements/windTurbine';
|
||||
@@ -33,6 +34,7 @@ export const defaultElementItems = [
|
||||
triangleItem,
|
||||
cloudItem,
|
||||
parallelogramItem,
|
||||
svgItem,
|
||||
];
|
||||
|
||||
export const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem];
|
||||
|
||||
@@ -34,7 +34,7 @@ composableKinds: PanelCfg: {
|
||||
vertical?: VerticalConstraint
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
Placement: {
|
||||
Placement: {
|
||||
top?: ui.PositionDimensionConfig
|
||||
left?: ui.PositionDimensionConfig
|
||||
right?: ui.PositionDimensionConfig
|
||||
@@ -44,7 +44,7 @@ composableKinds: PanelCfg: {
|
||||
height?: ui.PositionDimensionConfig
|
||||
|
||||
rotation?: ui.ScalarDimensionConfig
|
||||
} @cuetsy(kind="interface")
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile")
|
||||
BackgroundConfig: {
|
||||
|
||||
Reference in New Issue
Block a user