import React, { useEffect, useRef } from 'react'; import MonacoEditor from 'react-monaco-editor'; import * as monaco from 'monaco-editor'; import type * as monacoTypes from 'monaco-editor'; import { v4 as uuidv4 } from 'uuid'; import { css } from '@emotion/css'; import { installAutoHeightLayout } from '../shared/autoHeightLayout'; import { hideMirroredInputAreaClassName } from '../shared/hideMirroredInputArea'; import { language, languageConfiguration } from './expr'; import { getExprCompletionProvider } from './completion/getCompletionProvider'; import { validateExpr } from './validation'; import type { ExprEditorProps } from './types'; const EXPR_LANG_ID = 'expr'; const SIZE_MAP: Record< string, { className: string; top: number; bottom: number; minHeight: number; } > = { small: { className: 'ant-input-sm', top: 1, bottom: 1, minHeight: 24, }, middle: { className: 'ant-input-md', top: 1, bottom: 1, minHeight: 32, }, large: { className: 'ant-input-lg', top: 3, bottom: 2, minHeight: 40, }, }; const themeMap: Record = { light: 'expr-light', dark: 'expr-dark', }; const containerDisabledClassName = css` .monaco-editor { user-select: none; pointer-events: none; } `; const containerReadOnlyClassName = css` .monaco-editor .cursors-layer > .cursor { opacity: 0 !important; } `; export default function ExprEditor(props: ExprEditorProps) { const id = uuidv4(); const { className, maxHeight, fontSize, size = 'middle', theme = 'light', value = '', placeholder, enableAutocomplete = true, readOnly = false, disabled = false, onChange, onEnter, onBlur, onFocus, editorDidMount, } = props; const containerRef = useRef(null); const editorRef = useRef(null); const modelRef = useRef(null); const disposablesRef = useRef([]); useEffect(() => { // Register language if (!monaco.languages.getLanguages().some((lang) => lang.id === EXPR_LANG_ID)) { monaco.languages.register({ id: EXPR_LANG_ID }); monaco.languages.setMonarchTokensProvider(EXPR_LANG_ID, language as any); monaco.languages.setLanguageConfiguration(EXPR_LANG_ID, languageConfiguration); } // Register completion provider if (enableAutocomplete) { const completionProvider = getExprCompletionProvider(); const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = { ...completionProvider, provideCompletionItems: (model, position, context, token) => { if (editorRef.current?.getModel()?.id !== model.id) { return { suggestions: [] }; } return completionProvider.provideCompletionItems!(model, position, context, token); }, }; const disposable = monaco.languages.registerCompletionItemProvider(EXPR_LANG_ID, filteringCompletionProvider); disposablesRef.current.push(disposable); } return () => { disposablesRef.current.forEach((disposable) => disposable.dispose()); disposablesRef.current = []; }; }, [enableAutocomplete]); const handleEditorMount = (editor: monacoTypes.editor.IStandaloneCodeEditor) => { editorRef.current = editor; modelRef.current = editor.getModel(); monaco.editor.defineTheme('expr-light', { base: 'vs', inherit: true, rules: [], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); monaco.editor.defineTheme('expr-dark', { base: 'vs-dark', inherit: true, rules: [], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); const isEditorFocused = editor.createContextKey('isEditorFocused' + id, false); editor.onDidBlurEditorWidget(() => { isEditorFocused.set(false); onBlur?.(editor.getValue()); const position = editor.getPosition(); if (position) { const newSelection = new monaco.Selection(position.lineNumber, position.column, position.lineNumber, position.column); editor.setSelection(newSelection); } }); editor.onDidFocusEditorText(() => { isEditorFocused.set(true); onFocus?.(editor.getValue()); }); if (containerRef.current) { installAutoHeightLayout(editor, containerRef.current, { heightStyleProperty: 'minHeight' }); } // Disable search box monaco.editor.addKeybindingRule({ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, command: null, }); // Shift+Enter for newline editor.addCommand( monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { const position = editor.getPosition(); if (position) { editor.executeEdits('shift-enter', [ { range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column), text: '\n', }, ]); editor.setPosition({ lineNumber: position.lineNumber + 1, column: 1, }); } }, 'isEditorFocused' + id, ); // Prevent default Enter monaco.editor.addKeybindingRule({ keybinding: monaco.KeyCode.Enter, command: '-', when: '!suggestWidgetVisible', }); // Custom Enter handler editor.addCommand( monaco.KeyCode.Enter, () => { onEnter?.(editor.getValue()); }, '!suggestWidgetVisible && isEditorFocused' + id, ); // Setup validation on content change using decorations (no marker hover clutter) const model = editor.getModel(); let errorDecorations: string[] = []; if (model) { const updateDecorations = () => { const exprValue = model.getValue(); const markers = validateExpr(exprValue); const newDecorations: monaco.editor.IModelDeltaDecoration[] = markers.map((m) => ({ range: new monaco.Range(m.startLineNumber, m.startColumn, m.endLineNumber, m.endColumn), options: { className: 'expr-error-squiggly', hoverMessage: { value: m.message }, minimap: { color: '#e51400', position: 1 as monaco.editor.MinimapPosition }, overviewRuler: { color: '#e51400', position: monaco.editor.OverviewRulerLane.Right }, }, })); errorDecorations = model.deltaDecorations(errorDecorations, newDecorations); }; const validateDisposable = model.onDidChangeContent(updateDecorations); disposablesRef.current.push(validateDisposable); // Run initial validation updateDecorations(); } // Inject CSS for the red squiggly underline const styleEl = document.createElement('style'); styleEl.textContent = ` .expr-error-squiggly { background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 3' preserveAspectRatio='none'%3E%3Cpath d='M0,2.5 L1.5,1 L3,2.5 L4.5,1 L6,2.5' stroke='%23e51400' stroke-width='0.6' fill='none'/%3E%3C/svg%3E") repeat-x left bottom; background-size: 6px 3px; padding-bottom: 3px; } `; document.head.appendChild(styleEl); disposablesRef.current.push({ dispose: () => styleEl.remove() }); editorDidMount?.(editor); }; const handleChange = (newValue: string, _e: monacoTypes.editor.IModelContentChangedEvent) => { onChange?.(newValue); }; const themeValue = themeMap[theme]; return (
); }