/* eslint-disable import/no-unresolved */ import React, { useContext, useEffect, useMemo, useRef } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { defaultKeymap, indentWithTab } from '@codemirror/commands'; import { keymap } from '@codemirror/view'; import { completionKeymap, acceptCompletion, autocompletion, completionStatus } from '@codemirror/autocomplete'; import { python } from '@codemirror/lang-python'; import { sql } from '@codemirror/lang-sql'; import _ from 'lodash'; import { sass, sassCompletionSource } from '@codemirror/lang-sass'; import { okaidia } from '@uiw/codemirror-theme-okaidia'; import { githubLight } from '@uiw/codemirror-theme-github'; import { getSuggestionsForMultiLine } from './autocompleteExtensionConfig'; import ErrorBoundary from '@/_ui/ErrorBoundary'; import CodeHinter from './CodeHinter'; import { CodeHinterContext } from '../CodeBuilder/CodeHinterContext'; import { createReferencesLookup } from '@/_stores/utils'; import { PreviewBox } from './PreviewBox'; import useStore from '@/AppBuilder/_stores/store'; import { shallow } from 'zustand/shallow'; import { search, searchKeymap, searchPanelOpen } from '@codemirror/search'; import { handleSearchPanel } from './SearchBox'; import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks'; import { isInsideParent } from './utils'; import { CodeHinterBtns } from './CodehinterOverlayTriggers'; import useWorkflowStore from '@/_stores/workflowStore'; const langSupport = Object.freeze({ javascript: javascript(), python: python(), sql: sql(), jsx: javascript({ jsx: true }), css: sass(), }); const MultiLineCodeEditor = (props) => { const { darkMode, height, initialValue, lang, className, onChange, componentName, lineNumbers, placeholder, hideSuggestion, portalProps, showPreview, paramLabel = '', delayOnChange = true, // Added this prop to immediately update the onBlurUpdate callback readOnly = false, editable = true, renderCopilot, setCodeEditorView, onInputChange, // Added this prop to immediately handle value changes } = props; const editorRef = useRef(null); const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); const wrapperRef = useRef(null); const getSuggestions = useStore((state) => state.getSuggestions, shallow); const getServerSideGlobalResolveSuggestions = useStore( (state) => state.getServerSideGlobalResolveSuggestions, shallow ); const isInsideQueryPane = !!document.querySelector('.code-hinter-wrapper')?.closest('.query-details'); const isInsideQueryManager = useMemo( () => isInsideParent(wrapperRef?.current, 'query-manager'), [wrapperRef.current] ); const context = useContext(CodeHinterContext); const { workflowSuggestions } = useWorkflowStore((state) => ({ workflowSuggestions: state.suggestions }), shallow); const { suggestionList: paramList } = createReferencesLookup(context, true); const currentValueRef = useRef(initialValue); const [editorView, setEditorView] = React.useState(null); const [isSearchPanelOpen, setIsSearchPanelOpen] = React.useState(false); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onChange, currentValueRef, 'multiline'); // Add state for tracking autocomplete visibility const [showSuggestions, setShowSuggestions] = React.useState(true); const currentLineObserverRef = useRef(null); const isObserverTriggeredRef = useRef(false); // Intersection observer to detect when current line goes out of view useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.intersectionRatio < 1) { setShowSuggestions(false); isObserverTriggeredRef.current = true; // Close autocomplete dropdown by dispatching a selection change if (editorView) { editorView.dispatch({ selection: editorView.state.selection, }); } } else { setShowSuggestions(true); isObserverTriggeredRef.current = false; } }, { root: null, threshold: [1] } ); currentLineObserverRef.current = observer; return () => { if (currentLineObserverRef.current) { currentLineObserverRef.current.disconnect(); } }; }, [editorView]); const handleChange = (val) => { currentValueRef.current = val; onInputChange && onInputChange(val); }; const handleOnBlur = () => { if (!delayOnChange) return onChange(currentValueRef.current); setTimeout(() => { onChange(currentValueRef.current); }, 100); // eslint-disable-next-line react-hooks/exhaustive-deps }; const heightInPx = typeof height === 'string' && height?.includes('px') ? height : `${height}px`; const theme = darkMode ? okaidia : githubLight; const langExtention = langSupport[lang] ?? null; const setupConfig = { lineNumbers: lineNumbers ?? true, syntaxHighlighting: true, bracketMatching: true, foldGutter: true, highlightActiveLine: false, autocompletion: hideSuggestion ?? true, highlightActiveLineGutter: false, defaultKeymap: false, completionKeymap: true, searchKeymap: false, }; function autoCompleteExtensionConfig(context) { const hasWorkflowSuggestions = workflowSuggestions?.appHints?.length > 0 || workflowSuggestions?.jsHints?.length > 0; const hints = hasWorkflowSuggestions ? workflowSuggestions : getSuggestions(); const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager); const allHints = { ...hints, appHints: [...hints.appHints, ...serverHints], }; return getSuggestionsForMultiLine(context, allHints, hints, lang, paramList); } const customKeyMaps = [ ...defaultKeymap.filter((keyBinding) => keyBinding.key !== 'Mod-Enter'), // Remove default keybinding for Mod-Enter ...completionKeymap, ...searchKeymap, ]; const customTabKeymap = keymap.of([ { key: 'Tab', run: (view) => { if (completionStatus(view.state)) { return acceptCompletion(view); } const { state } = view; const { selection } = state; const { anchor } = selection.main; const tabSize = 2; view.dispatch({ changes: { from: anchor, insert: ' '.repeat(tabSize) }, selection: { anchor: anchor + tabSize }, }); return true; }, }, ...queryPanelKeybindings, ]); // eslint-disable-next-line react-hooks/exhaustive-deps const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [paramList]); const { handleTogglePopupExapand, isOpen, setIsOpen, forceUpdate } = portalProps; let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel; const initialValueWithReplacedIds = useMemo(() => { if ( typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries')) ) { return replaceIdsWithName(initialValue); } return initialValue; }, [initialValue, replaceIdsWithName]); function updateCurrentLineObserver(editorView) { if (!editorView || !editorView?.view?.dom) return; const cursorPos = editorView.state.selection.main.head; const line = editorView.state.doc.lineAt(cursorPos); const lineNumber = line.number; const cmLines = editorView.view.dom.querySelectorAll('.cm-line'); const currentLineDiv = cmLines[lineNumber - 1] || null; // Update intersection observer to watch the current line if (currentLineObserverRef.current && currentLineDiv && !isObserverTriggeredRef.current) { currentLineObserverRef.current.disconnect(); currentLineObserverRef.current.observe(currentLineDiv); } } const onAiSuggestionAccept = (newValue) => { currentValueRef.current = newValue; onChange(newValue); }; return (
renderCopilot?.({ darkMode, language: lang, editorRef, onAiSuggestionAccept, }) } />
{ return a.section.rank - b.section.rank && a.label.localeCompare(b.label); }, }), customTabKeymap, keymap.of([...customKeyMaps]), ]} onChange={handleChange} onBlur={handleOnBlur} basicSetup={setupConfig} style={{ overflowY: 'auto', }} className={`codehinter-multi-line-input ${isInsideQueryPane ? 'code-editor-query-panel' : ''}`} indentWithTab={false} readOnly={readOnly} editable={editable} //for transformations in query manager onCreateEditor={(view) => { setEditorView(view); if (setCodeEditorView) { setCodeEditorView(view); } }} onUpdate={(view) => { setIsSearchPanelOpen(searchPanelOpen(view.state)); updateCurrentLineObserver(view); }} />
{showPreview && (
null} componentId={null} setErrorMessage={() => null} />
)}
); }; export default MultiLineCodeEditor;