create abstraction

This commit is contained in:
ismail simsek
2026-01-12 23:06:32 +01:00
parent 317bc94634
commit d84652d8aa
9 changed files with 788 additions and 290 deletions
@@ -0,0 +1,198 @@
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
lineNumbers,
placeholder as placeholderExtension,
rectangularSelection,
tooltips,
ViewUpdate,
} from '@codemirror/view';
import { css, cx } from '@emotion/css';
import { memo, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { getInputStyles } from '../Input/Input';
import { createGenericHighlighter } from './highlight';
import { createGenericTheme } from './styles';
import { CodeMirrorEditorProps } from './types';
export const CodeMirrorEditor = memo((props: CodeMirrorEditorProps) => {
const {
value,
onChange,
placeholder = '',
themeFactory,
highlighterFactory,
highlightConfig,
autocompletion: autocompletionExtension,
extensions = [],
showLineNumbers = false,
lineWrapping = true,
ariaLabel,
className,
useInputStyles = true,
closeBrackets: enableCloseBrackets = true,
} = props;
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
const styles = useStyles2((theme) => getStyles(theme, useInputStyles));
const theme = useTheme2();
const themeCompartment = useRef(new Compartment());
const autocompletionCompartment = useRef(new Compartment());
const customKeymap = keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]);
// Build theme extensions
const getThemeExtensions = () => {
const themeExt = themeFactory ? themeFactory(theme) : createGenericTheme(theme);
const highlighterExt =
highlighterFactory && highlightConfig
? highlighterFactory(highlightConfig)
: highlightConfig
? createGenericHighlighter(highlightConfig)
: [];
return [themeExt, highlighterExt];
};
// Initialize CodeMirror editor
useEffect(() => {
if (!editorContainerRef.current || editorViewRef.current) {
return;
}
const baseExtensions = [
highlightActiveLine(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
rectangularSelection(),
customKeymap,
placeholderExtension(placeholder),
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
tooltips({
parent: document.body, // Render tooltips at body level to prevent clipping by modals
}),
themeCompartment.current.of(getThemeExtensions()),
EditorState.phrases.of({
next: 'Next',
previous: 'Previous',
Completions: 'Completions',
}),
EditorView.editorAttributes.of({ 'aria-label': ariaLabel || placeholder }),
];
// Conditionally add closeBrackets extension
if (enableCloseBrackets) {
baseExtensions.push(closeBrackets());
}
// Add optional extensions
if (showLineNumbers) {
baseExtensions.push(lineNumbers());
}
if (lineWrapping) {
baseExtensions.push(EditorView.lineWrapping);
}
if (autocompletionExtension) {
baseExtensions.push(autocompletionCompartment.current.of(autocompletionExtension));
}
// Add custom extensions
if (extensions.length > 0) {
baseExtensions.push(...extensions);
}
const startState = EditorState.create({
doc: value,
extensions: baseExtensions,
});
const view = new EditorView({
state: startState,
parent: editorContainerRef.current,
});
editorViewRef.current = view;
return () => {
view.destroy();
editorViewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor value when prop changes
useEffect(() => {
if (editorViewRef.current) {
const currentValue = editorViewRef.current.state.doc.toString();
if (currentValue !== value) {
editorViewRef.current.dispatch({
changes: { from: 0, to: currentValue.length, insert: value },
});
}
}
}, [value]);
// Update theme when it changes
useEffect(() => {
if (editorViewRef.current) {
editorViewRef.current.dispatch({
effects: themeCompartment.current.reconfigure(getThemeExtensions()),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme, themeFactory, highlighterFactory, highlightConfig]);
// Update autocompletion when it changes
useEffect(() => {
if (editorViewRef.current && autocompletionExtension) {
editorViewRef.current.dispatch({
effects: autocompletionCompartment.current.reconfigure(autocompletionExtension),
});
}
}, [autocompletionExtension]);
return (
<div className={cx(styles.container, className)}>
<div className={styles.input} ref={editorContainerRef} />
</div>
);
});
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
const getStyles = (theme: GrafanaTheme2, useInputStyles: boolean) => {
const baseInputStyles = useInputStyles ? getInputStyles({ theme, invalid: false }).input : {};
return {
container: css({
position: 'relative',
width: '100%',
}),
input: css(baseInputStyles),
};
};
@@ -0,0 +1,246 @@
# CodeMirror Editor Component
A reusable CodeMirror editor component for Grafana that provides a flexible and themeable code editing experience.
## Overview
The `CodeMirrorEditor` component is a generic, theme-aware editor built on CodeMirror 6. Use it anywhere you need code editing functionality with syntax highlighting, autocompletion, and Grafana theme integration.
## Basic usage
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
function MyComponent() {
const [value, setValue] = useState('');
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
placeholder="Enter your code here"
/>
);
}
```
## Advanced usage
### Custom syntax highlighting
Create a custom highlighter for your specific syntax:
```typescript
import { CodeMirrorEditor, SyntaxHighlightConfig } from '@grafana/ui';
function MyComponent() {
const [value, setValue] = useState('');
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\b(SELECT|FROM|WHERE)\b/gi, // Highlight SQL keywords
className: 'cm-keyword',
};
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
highlightConfig={highlightConfig}
/>
);
}
```
### Custom theme
Extend the default theme with your own styling:
```typescript
import { CodeMirrorEditor, ThemeFactory } from '@grafana/ui';
import { EditorView } from '@codemirror/view';
import { createGenericTheme } from '@grafana/ui';
const myCustomTheme: ThemeFactory = (theme) => {
const baseTheme = createGenericTheme(theme);
const customStyles = EditorView.theme({
'.cm-keyword': {
color: theme.colors.primary.text,
fontWeight: theme.typography.fontWeightBold,
},
'.cm-string': {
color: theme.colors.success.text,
},
});
return [baseTheme, customStyles];
};
function MyComponent() {
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
themeFactory={myCustomTheme}
/>
);
}
```
### Custom autocompletion
Add autocompletion for your specific use case:
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
function MyComponent() {
const [value, setValue] = useState('');
const autocompletionExtension = useMemo(() => {
return autocompletion({
override: [(context: CompletionContext) => {
const word = context.matchBefore(/\w*/);
if (!word || word.from === word.to) {
return null;
}
return {
from: word.from,
options: [
{ label: 'hello', type: 'keyword' },
{ label: 'world', type: 'keyword' },
],
};
}],
activateOnTyping: true,
});
}, []);
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
autocompletion={autocompletionExtension}
/>
);
}
```
### Additional extensions
Add custom CodeMirror extensions:
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
import { javascript } from '@codemirror/lang-javascript';
import { linter } from '@codemirror/lint';
function MyComponent() {
const extensions = useMemo(() => [
javascript(),
linter(/* your linting logic */),
], []);
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
extensions={extensions}
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | required | The current value of the editor |
| `onChange` | `(value: string, callback?: () => void) => void` | required | Callback when the editor value changes |
| `placeholder` | `string` | `''` | Placeholder text when editor is empty |
| `themeFactory` | `ThemeFactory` | `createGenericTheme` | Custom theme factory function |
| `highlighterFactory` | `HighlighterFactory` | `createGenericHighlighter` | Custom syntax highlighter factory |
| `highlightConfig` | `SyntaxHighlightConfig` | `undefined` | Configuration for syntax highlighting |
| `autocompletion` | `Extension` | `undefined` | Custom autocompletion extension |
| `extensions` | `Extension[]` | `[]` | Additional CodeMirror extensions |
| `showLineNumbers` | `boolean` | `false` | Whether to show line numbers |
| `lineWrapping` | `boolean` | `true` | Whether to enable line wrapping |
| `ariaLabel` | `string` | `placeholder` | Aria label for accessibility |
| `className` | `string` | `undefined` | Custom CSS class for the container |
| `useInputStyles` | `boolean` | `true` | Whether to apply Grafana input styles |
## Example: DataLink editor
Here's how the DataLink component uses the CodeMirror editor:
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
export const DataLinkInput = memo(({ value, onChange, suggestions, placeholder }) => {
const autocompletionExtension = useMemo(
() => createDataLinkAutocompletion(suggestions),
[suggestions]
);
return (
<CodeMirrorEditor
value={value}
onChange={onChange}
placeholder={placeholder}
themeFactory={createDataLinkTheme}
highlighterFactory={createDataLinkHighlighter}
autocompletion={autocompletionExtension}
ariaLabel={placeholder}
/>
);
});
```
## Utilities
### `createGenericTheme(theme: GrafanaTheme2): Extension`
Creates a generic CodeMirror theme based on Grafana's theme.
### `createGenericHighlighter(theme: GrafanaTheme2, config: SyntaxHighlightConfig): Extension`
Creates a generic syntax highlighter based on a regex pattern and CSS class name.
## Types
```typescript
interface SyntaxHighlightConfig {
pattern: RegExp;
className: string;
}
type ThemeFactory = (theme: GrafanaTheme2) => Extension;
type HighlighterFactory = (theme: GrafanaTheme2, config?: SyntaxHighlightConfig) => Extension;
type AutocompletionFactory<T = unknown> = (data: T) => Extension;
```
## Features
- **Theme-aware**: Automatically adapts to Grafana's light and dark themes
- **Syntax highlighting**: Configurable pattern-based syntax highlighting
- **Autocompletion**: Customizable autocompletion with keyboard shortcuts
- **Accessibility**: Built-in ARIA support
- **Line numbers**: Optional line number display
- **Line wrapping**: Configurable line wrapping
- **Modal-friendly**: Tooltips render at body level to prevent clipping
- **Extensible**: Support for custom CodeMirror extensions
## Best practices
1. **Memoize extensions**: Use `useMemo` to create autocompletion and other extensions to avoid recreating them on every render.
2. **Custom themes**: Extend the generic theme rather than replacing it to maintain consistency with Grafana's design system.
3. **Pattern efficiency**: Use efficient regex patterns for syntax highlighting to avoid performance issues with large documents.
4. **Accessibility**: Always provide meaningful `ariaLabel` or `placeholder` text for screen readers.
5. **Type safety**: Use the provided TypeScript types for better type safety and IDE support.
@@ -0,0 +1,54 @@
import { Extension } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { SyntaxHighlightConfig } from './types';
/**
* Creates a generic syntax highlighter based on a pattern and class name
*/
export function createGenericHighlighter(config: SyntaxHighlightConfig): Extension {
const { pattern, className } = config;
const decoration = Decoration.mark({
class: className,
});
const viewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations: Array<{ from: number; to: number }> = [];
const text = view.state.doc.toString();
let match;
// Reset regex state
pattern.lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
decorations.push({
from: match.index,
to: match.index + match[0].length,
});
}
return Decoration.set(decorations.map((range) => decoration.range(range.from, range.to)));
}
},
{
decorations: (v) => v.decorations,
}
);
return viewPlugin;
}
@@ -0,0 +1,90 @@
import { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { GrafanaTheme2 } from '@grafana/data';
/**
* Creates a generic CodeMirror theme based on Grafana's theme
*/
export function createGenericTheme(theme: GrafanaTheme2): Extension {
const isDark = theme.colors.mode === 'dark';
return EditorView.theme(
{
'&': {
fontSize: theme.typography.body.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
backgroundColor: 'transparent',
border: 'none',
outline: 'none',
},
'.cm-placeholder': {
color: theme.colors.text.disabled,
fontStyle: 'normal',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: theme.typography.fontFamilyMonospace,
},
'.cm-content': {
padding: '3px 0',
color: theme.colors.text.primary,
caretColor: theme.colors.text.primary,
},
'.cm-line': {
padding: '0 2px',
},
'.cm-cursor': {
borderLeftColor: theme.colors.text.primary,
},
'.cm-selectionBackground': {
backgroundColor: `${theme.colors.action.selected} !important`,
},
'&.cm-focused .cm-selectionBackground': {
backgroundColor: `${theme.colors.action.focus} !important`,
},
'.cm-activeLine': {
backgroundColor: 'transparent',
},
'.cm-gutters': {
display: 'none',
},
'.cm-tooltip': {
zIndex: theme.zIndex.portal + 1, // Above modals and portals (1062)
},
'.cm-tooltip.cm-tooltip-autocomplete': {
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z3,
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontFamily: theme.typography.fontFamily,
maxHeight: '300px',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
padding: '2px 8px',
color: theme.colors.text.primary,
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: theme.colors.background.secondary,
color: theme.colors.text.primary,
},
'.cm-completionLabel': {
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.size.sm,
},
'.cm-completionDetail': {
color: theme.colors.text.secondary,
fontStyle: 'normal',
marginLeft: theme.spacing(1),
},
'.cm-completionInfo': {
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
color: theme.colors.text.primary,
padding: theme.spacing(1),
},
},
{ dark: isDark }
);
}
@@ -0,0 +1,107 @@
import { Extension } from '@codemirror/state';
import { GrafanaTheme2 } from '@grafana/data';
/**
* Configuration options for syntax highlighting
*/
export interface SyntaxHighlightConfig {
/**
* Pattern to match for highlighting
*/
pattern: RegExp;
/**
* CSS class to apply to matched text
*/
className: string;
}
/**
* Function to create a theme extension
*/
export type ThemeFactory = (theme: GrafanaTheme2) => Extension;
/**
* Function to create a syntax highlighter extension
*/
export type HighlighterFactory = (config?: SyntaxHighlightConfig) => Extension;
/**
* Function to create an autocompletion extension
*/
export type AutocompletionFactory<T = unknown> = (data: T) => Extension;
/**
* Props for the CodeMirrorEditor component
*/
export interface CodeMirrorEditorProps {
/**
* The current value of the editor
*/
value: string;
/**
* Callback when the editor value changes
*/
onChange: (value: string, callback?: () => void) => void;
/**
* Placeholder text to display when editor is empty
*/
placeholder?: string;
/**
* Custom theme factory function
*/
themeFactory?: ThemeFactory;
/**
* Custom syntax highlighter factory function
*/
highlighterFactory?: HighlighterFactory;
/**
* Configuration for syntax highlighting
*/
highlightConfig?: SyntaxHighlightConfig;
/**
* Custom autocompletion extension
*/
autocompletion?: Extension;
/**
* Additional CodeMirror extensions to apply
*/
extensions?: Extension[];
/**
* Whether to show line numbers (default: false)
*/
showLineNumbers?: boolean;
/**
* Whether to enable line wrapping (default: true)
*/
lineWrapping?: boolean;
/**
* Aria label for accessibility
*/
ariaLabel?: string;
/**
* Custom CSS class for the container
*/
className?: string;
/**
* Whether to apply input styles (default: true)
*/
useInputStyles?: boolean;
/**
* Whether to enable automatic closing of brackets and braces (default: true)
*/
closeBrackets?: boolean;
}
@@ -7,6 +7,26 @@ import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafan
import { DataLinkInput } from './DataLinkInput';
// Mock getClientRects for CodeMirror in JSDOM
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
const mockSuggestions: VariableSuggestion[] = [
{
value: DataLinkBuiltInVars.seriesName,
@@ -49,7 +69,7 @@ describe('DataLinkInput', () => {
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toHaveAttribute('aria-label', placeholder);
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
});
});
@@ -87,7 +107,7 @@ describe('DataLinkInput', () => {
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
@@ -106,7 +126,7 @@ describe('DataLinkInput', () => {
await user.keyboard('=');
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
@@ -125,13 +145,13 @@ describe('DataLinkInput', () => {
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
@@ -150,7 +170,7 @@ describe('DataLinkInput', () => {
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
// Navigate with arrow keys
@@ -158,7 +178,7 @@ describe('DataLinkInput', () => {
await user.keyboard('{ArrowUp}');
// Menu should still be visible
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('inserts variable on Enter key', async () => {
@@ -176,13 +196,13 @@ describe('DataLinkInput', () => {
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await user.keyboard('{Enter}');
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
// Should have called onChange with the inserted variable
@@ -223,7 +243,7 @@ describe('DataLinkInput', () => {
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toHaveAttribute('aria-label', 'http://your-grafana.com/d/000000010/annotations');
expect(editor).toHaveAttribute('aria-placeholder', 'http://your-grafana.com/d/000000010/annotations');
});
});
});
@@ -1,29 +1,10 @@
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
lineNumbers,
placeholder as placeholderExtension,
rectangularSelection,
tooltips,
ViewUpdate,
} from '@codemirror/view';
import { css } from '@emotion/css';
import { memo, useEffect, useRef } from 'react';
import { memo, useMemo } from 'react';
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
import { VariableSuggestion } from '@grafana/data';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { getInputStyles } from '../Input/Input';
import { CodeMirrorEditor } from '../CodeMirror/CodeMirrorEditor';
import { createDataLinkHighlighter, createDataLinkTheme, dataLinkAutocompletion } from './codemirrorUtils';
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
interface DataLinkInputProps {
value: string;
@@ -39,139 +20,22 @@ export const DataLinkInput = memo(
suggestions,
placeholder = 'http://your-grafana.com/d/000000010/annotations',
}: DataLinkInputProps) => {
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
const styles = useStyles2(getStyles);
const theme = useTheme2();
const themeCompartment = useRef(new Compartment());
const suggestionsCompartment = useRef(new Compartment());
const customKeymap = keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]);
// Initialize CodeMirror editor
useEffect(() => {
if (!editorContainerRef.current || editorViewRef.current) {
return;
}
const startState = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
closeBrackets(),
rectangularSelection(),
customKeymap,
placeholderExtension(placeholder),
EditorView.lineWrapping,
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
tooltips({
parent: document.body, // Render tooltips at body level to prevent clipping by modals
}),
themeCompartment.current.of([createDataLinkTheme(theme), createDataLinkHighlighter(theme)]),
suggestionsCompartment.current.of(
autocompletion({
override: [dataLinkAutocompletion(suggestions)],
activateOnTyping: true,
closeOnBlur: true,
maxRenderedOptions: 100,
defaultKeymap: true,
interactionDelay: 0,
})
),
EditorState.phrases.of({
next: 'Next',
previous: 'Previous',
Completions: 'Completions',
}),
EditorView.editorAttributes.of({ 'aria-label': placeholder }),
],
});
const view = new EditorView({
state: startState,
parent: editorContainerRef.current,
});
editorViewRef.current = view;
return () => {
view.destroy();
editorViewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor value when prop changes
useEffect(() => {
if (editorViewRef.current) {
const currentValue = editorViewRef.current.state.doc.toString();
if (currentValue !== value) {
editorViewRef.current.dispatch({
changes: { from: 0, to: currentValue.length, insert: value },
});
}
}
}, [value]);
// Update theme when it changes
useEffect(() => {
if (editorViewRef.current) {
editorViewRef.current.dispatch({
effects: themeCompartment.current.reconfigure([createDataLinkTheme(theme), createDataLinkHighlighter(theme)]),
});
}
}, [theme]);
// Update suggestions when they change
useEffect(() => {
if (editorViewRef.current) {
editorViewRef.current.dispatch({
effects: suggestionsCompartment.current.reconfigure(
autocompletion({
override: [dataLinkAutocompletion(suggestions)],
activateOnTyping: true,
closeOnBlur: true,
maxRenderedOptions: 100,
defaultKeymap: true,
interactionDelay: 0,
})
),
});
}
}, [suggestions]);
// Memoize autocompletion extension to avoid recreating on every render
const autocompletionExtension = useMemo(() => createDataLinkAutocompletion(suggestions), [suggestions]);
return (
<div className={styles.container}>
<div className={styles.input} ref={editorContainerRef} />
</div>
<CodeMirrorEditor
value={value}
onChange={onChange}
placeholder={placeholder}
themeFactory={createDataLinkTheme}
highlighterFactory={createDataLinkHighlighter}
autocompletion={autocompletionExtension}
ariaLabel={placeholder}
closeBrackets={false}
/>
);
}
);
DataLinkInput.displayName = 'DataLinkInput';
const getStyles = (theme: GrafanaTheme2) => {
const baseInputStyles = getInputStyles({ theme, invalid: false }).input;
return {
container: css({
position: 'relative',
width: '100%',
}),
input: css(baseInputStyles),
};
};
@@ -1,153 +1,46 @@
import { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { autocompletion, Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { Extension } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { EditorView } from '@codemirror/view';
import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
import { createGenericHighlighter } from '../CodeMirror/highlight';
import { createGenericTheme } from '../CodeMirror/styles';
/**
* Creates a CodeMirror theme for data link input based on Grafana's theme
* Creates a CodeMirror theme for data link input with custom variable styling
* This extends the generic theme with data link-specific styles
*/
export function createDataLinkTheme(theme: GrafanaTheme2): Extension {
const isDark = theme.colors.mode === 'dark';
const genericTheme = createGenericTheme(theme);
return EditorView.theme(
{
'&': {
fontSize: theme.typography.body.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
backgroundColor: 'transparent',
border: 'none',
outline: 'none',
},
'.cm-placeholder': {
color: theme.colors.text.disabled,
fontStyle: 'normal',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: theme.typography.fontFamilyMonospace,
},
'.cm-content': {
padding: '3px 0',
color: theme.colors.text.primary,
caretColor: theme.colors.text.primary,
},
'.cm-line': {
padding: '0 2px',
},
'.cm-cursor': {
borderLeftColor: theme.colors.text.primary,
},
'.cm-selectionBackground': {
backgroundColor: `${theme.colors.action.selected} !important`,
},
'&.cm-focused .cm-selectionBackground': {
backgroundColor: `${theme.colors.action.focus} !important`,
},
'.cm-variable': {
color: theme.colors.success.text,
fontWeight: theme.typography.fontWeightMedium,
},
'.cm-activeLine': {
backgroundColor: 'transparent',
},
'.cm-gutters': {
display: 'none',
},
'.cm-tooltip': {
zIndex: theme.zIndex.portal + 1, // Above modals and portals (1062)
},
'.cm-tooltip.cm-tooltip-autocomplete': {
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z3,
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontFamily: theme.typography.fontFamily,
maxHeight: '300px',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
padding: '2px 8px',
color: theme.colors.text.primary,
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: theme.colors.background.secondary,
color: theme.colors.text.primary,
},
'.cm-completionLabel': {
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.size.sm,
},
'.cm-completionDetail': {
color: theme.colors.text.secondary,
fontStyle: 'normal',
marginLeft: theme.spacing(1),
},
'.cm-completionInfo': {
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
color: theme.colors.text.primary,
padding: theme.spacing(1),
},
// Add data link-specific variable styling
const dataLinkStyles = EditorView.theme({
'.cm-variable': {
color: theme.colors.success.text,
fontWeight: theme.typography.fontWeightMedium,
},
{ dark: isDark }
);
});
return [genericTheme, dataLinkStyles];
}
/**
* Creates a syntax highlighter for data link variables (${...})
* Matches the pattern from the old Prism implementation: (\${\S+?})
*/
export function createDataLinkHighlighter(theme: GrafanaTheme2): Extension {
export function createDataLinkHighlighter(): Extension {
// Regular expression matching ${...} patterns (same as old implementation)
const variablePattern = /\$\{[^}]+\}/g;
const variableDecoration = Decoration.mark({
class: 'cm-variable',
return createGenericHighlighter({
pattern: variablePattern,
className: 'cm-variable',
});
const viewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations: Array<{ from: number; to: number }> = [];
const text = view.state.doc.toString();
let match;
// Reset regex state
variablePattern.lastIndex = 0;
while ((match = variablePattern.exec(text)) !== null) {
decorations.push({
from: match.index,
to: match.index + match[0].length,
});
}
return Decoration.set(decorations.map((range) => variableDecoration.range(range.from, range.to)));
}
},
{
decorations: (v) => v.decorations,
}
);
return viewPlugin;
}
/**
* Creates autocomplete function for data link variables
* Creates autocomplete source function for data link variables
* Triggers on $ and = characters
*/
export function dataLinkAutocompletion(
@@ -239,3 +132,17 @@ export function dataLinkAutocompletion(
};
};
}
/**
* Creates a data link autocompletion extension with configured suggestions
*/
export function createDataLinkAutocompletion(suggestions: VariableSuggestion[]): Extension {
return autocompletion({
override: [dataLinkAutocompletion(suggestions)],
activateOnTyping: true,
closeOnBlur: true,
maxRenderedOptions: 100,
defaultKeymap: true,
interactionDelay: 0,
});
}
+12
View File
@@ -92,6 +92,18 @@ export {
} from './components/Monaco/types';
export { variableSuggestionToCodeEditorSuggestion } from './components/Monaco/utils';
// CodeMirror
export { CodeMirrorEditor } from './components/CodeMirror/CodeMirrorEditor';
export { createGenericTheme } from './components/CodeMirror/styles';
export { createGenericHighlighter } from './components/CodeMirror/highlight';
export type {
CodeMirrorEditorProps,
ThemeFactory,
HighlighterFactory,
AutocompletionFactory,
SyntaxHighlightConfig,
} from './components/CodeMirror/types';
// TODO: namespace
export { Modal, type Props as ModalProps } from './components/Modal/Modal';
export { ModalHeader } from './components/Modal/ModalHeader';