create abstraction
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user