diff --git a/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.tsx b/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.tsx new file mode 100644 index 00000000000..17b71c7f3ad --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.tsx @@ -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(null); + const editorViewRef = useRef(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 ( +
+
+
+ ); +}); + +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), + }; +}; diff --git a/packages/grafana-ui/src/components/CodeMirror/README.md b/packages/grafana-ui/src/components/CodeMirror/README.md new file mode 100644 index 00000000000..3d48a5eff9e --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/README.md @@ -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 ( + + ); +} +``` + +## 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 ( + + ); +} +``` + +### 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 ( + + ); +} +``` + +### 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 ( + + ); +} +``` + +### 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 ( + + ); +} +``` + +## 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 ( + + ); +}); +``` + +## 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 = (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. diff --git a/packages/grafana-ui/src/components/CodeMirror/highlight.ts b/packages/grafana-ui/src/components/CodeMirror/highlight.ts new file mode 100644 index 00000000000..64109a2ac58 --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/highlight.ts @@ -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; +} diff --git a/packages/grafana-ui/src/components/CodeMirror/styles.ts b/packages/grafana-ui/src/components/CodeMirror/styles.ts new file mode 100644 index 00000000000..b3d83aa3769 --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/styles.ts @@ -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 } + ); +} diff --git a/packages/grafana-ui/src/components/CodeMirror/types.ts b/packages/grafana-ui/src/components/CodeMirror/types.ts new file mode 100644 index 00000000000..ee75f1b4941 --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/types.ts @@ -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 = (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; +} diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx index f68e301f7f8..25d8a4732ec 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx @@ -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'); }); }); }); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 2ba85455a44..0d2dd8549a8 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -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(null); - const editorViewRef = useRef(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 ( -
-
-
+ ); } ); DataLinkInput.displayName = 'DataLinkInput'; - -const getStyles = (theme: GrafanaTheme2) => { - const baseInputStyles = getInputStyles({ theme, invalid: false }).input; - - return { - container: css({ - position: 'relative', - width: '100%', - }), - input: css(baseInputStyles), - }; -}; diff --git a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts index 16fc4d643f7..c75d87e3333 100644 --- a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts +++ b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts @@ -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, + }); +} diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index e567e3dd561..8c3e25d6e18 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -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';