import React, { forwardRef, useCallback, useEffect, useImperativeHandle, 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 './sql'; import { getSqlCompletionProvider } from './completion/getCompletionProvider'; import { formatSql } from './format'; interface SqlEditorProps { className?: string; maxHeight?: number | string; fontSize?: number; size?: 'small' | 'middle' | 'large'; theme?: 'light' | 'dark'; value?: string; placeholder?: string; enableAutocomplete?: boolean; readOnly?: boolean; disabled?: boolean; onChange?: (value: string) => void; onEnter?: (value: string) => void; onBlur?: (value: string) => void; onFocus?: (value: string) => void; editorDidMount?: (editor: monacoTypes.editor.IStandaloneCodeEditor) => void; /** Whether to show the format button. Default: false */ enableFormat?: boolean; /** Custom format button renderer */ renderFormatButton?: (handleFormat: () => void) => React.ReactNode; } export interface SqlEditorHandle { format: () => void; } const SQL_LANG_ID = 'sql'; 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: 'sql-light', dark: 'sql-dark', }; const containerDisabledClassName = css` .monaco-editor { user-select: none; pointer-events: none; } `; const containerReadOnlyClassName = css` .monaco-editor .cursors-layer > .cursor { opacity: 0 !important; } `; const cursorVisibleClassName = css` .monaco-editor .cursors-layer > .cursor { background-color: var(--sql-editor-cursor-color) !important; } `; const formatBtnClassName = css` position: absolute; top: 4px; right: 4px; z-index: 10; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: none; background: transparent; border-radius: 4px; cursor: pointer; color: inherit; opacity: 1; transition: opacity 0.15s; &:hover { background: var(--format-btn-hover-bg, rgba(128, 128, 128, 0.15)); } `; const SqlEditor = forwardRef((props, ref) => { const id = uuidv4(); const { className, maxHeight, fontSize, size = 'middle', theme = 'light', value = '', placeholder, enableFormat = false, enableAutocomplete = true, readOnly = false, disabled = false, onChange, onEnter, onBlur, onFocus, editorDidMount, renderFormatButton, } = 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 === SQL_LANG_ID)) { monaco.languages.register({ id: SQL_LANG_ID }); monaco.languages.setMonarchTokensProvider(SQL_LANG_ID, language as any); monaco.languages.setLanguageConfiguration(SQL_LANG_ID, languageConfiguration); } // Register completion provider if (enableAutocomplete) { const completionProvider = getSqlCompletionProvider(); 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(SQL_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('sql-light', { base: 'vs', inherit: true, rules: [], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); monaco.editor.defineTheme('sql-dark', { base: 'vs-dark', inherit: true, rules: [], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); const isEditorFocused = editor.createContextKey('isEditorFocused' + id, false); // we setup on-blur editor.onDidBlurEditorWidget(() => { isEditorFocused.set(false); onBlur?.(editor.getValue()); // reset the selection to the current position 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' }); } // Fixes Monaco capturing the search key binding and displaying a useless search box within the Editor. monaco.editor.addKeybindingRule({ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, command: null, }); // 设置 Shift + Enter 为在光标位置换行 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, ); // 完全阻止 Enter 键的默认行为(包括换行) monaco.editor.addKeybindingRule({ keybinding: monaco.KeyCode.Enter, command: '-', when: '!suggestWidgetVisible', }); // handle: enter - 只有在没有建议窗口时才执行自定义行为 editor.addCommand( monaco.KeyCode.Enter, () => { onEnter?.(editor.getValue()); }, '!suggestWidgetVisible && isEditorFocused' + id, ); editorDidMount?.(editor); }; const handleFormat = useCallback(() => { const editor = editorRef.current; if (!editor) return; const currentValue = editor.getValue(); const formatted = formatSql(currentValue); editor.setValue(formatted); }, []); useImperativeHandle(ref, () => ({ format: handleFormat, })); const themeValue = themeMap[theme]; return (
{enableFormat && (renderFormatButton ? (
{renderFormatButton(handleFormat)}
) : ( ))}
); }); export { default as SqlPreview } from './preview'; export default SqlEditor;