| import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete'; |
| import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; |
| import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language'; |
| import { searchKeymap } from '@codemirror/search'; |
| import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state'; |
| import { |
| drawSelection, |
| dropCursor, |
| EditorView, |
| highlightActiveLine, |
| highlightActiveLineGutter, |
| keymap, |
| lineNumbers, |
| scrollPastEnd, |
| showTooltip, |
| tooltips, |
| type Tooltip, |
| } from '@codemirror/view'; |
| import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react'; |
| import type { Theme } from '~/types/theme'; |
| import { classNames } from '~/utils/classNames'; |
| import { debounce } from '~/utils/debounce'; |
| import { createScopedLogger, renderLogger } from '~/utils/logger'; |
| import { BinaryContent } from './BinaryContent'; |
| import { getTheme, reconfigureTheme } from './cm-theme'; |
| import { indentKeyBinding } from './indent'; |
| import { getLanguage } from './languages'; |
|
|
| const logger = createScopedLogger('CodeMirrorEditor'); |
|
|
| export interface EditorDocument { |
| value: string; |
| isBinary: boolean; |
| filePath: string; |
| scroll?: ScrollPosition; |
| } |
|
|
| export interface EditorSettings { |
| fontSize?: string; |
| gutterFontSize?: string; |
| tabSize?: number; |
| } |
|
|
| type TextEditorDocument = EditorDocument & { |
| value: string; |
| }; |
|
|
| export interface ScrollPosition { |
| top: number; |
| left: number; |
| } |
|
|
| export interface EditorUpdate { |
| selection: EditorSelection; |
| content: string; |
| } |
|
|
| export type OnChangeCallback = (update: EditorUpdate) => void; |
| export type OnScrollCallback = (position: ScrollPosition) => void; |
| export type OnSaveCallback = () => void; |
|
|
| interface Props { |
| theme: Theme; |
| id?: unknown; |
| doc?: EditorDocument; |
| editable?: boolean; |
| debounceChange?: number; |
| debounceScroll?: number; |
| autoFocusOnDocumentChange?: boolean; |
| onChange?: OnChangeCallback; |
| onScroll?: OnScrollCallback; |
| onSave?: OnSaveCallback; |
| className?: string; |
| settings?: EditorSettings; |
| } |
|
|
| type EditorStates = Map<string, EditorState>; |
|
|
| const readOnlyTooltipStateEffect = StateEffect.define<boolean>(); |
|
|
| const editableTooltipField = StateField.define<readonly Tooltip[]>({ |
| create: () => [], |
| update(_tooltips, transaction) { |
| if (!transaction.state.readOnly) { |
| return []; |
| } |
|
|
| for (const effect of transaction.effects) { |
| if (effect.is(readOnlyTooltipStateEffect) && effect.value) { |
| return getReadOnlyTooltip(transaction.state); |
| } |
| } |
|
|
| return []; |
| }, |
| provide: (field) => { |
| return showTooltip.computeN([field], (state) => state.field(field)); |
| }, |
| }); |
|
|
| const editableStateEffect = StateEffect.define<boolean>(); |
|
|
| const editableStateField = StateField.define<boolean>({ |
| create() { |
| return true; |
| }, |
| update(value, transaction) { |
| for (const effect of transaction.effects) { |
| if (effect.is(editableStateEffect)) { |
| return effect.value; |
| } |
| } |
|
|
| return value; |
| }, |
| }); |
|
|
| export const CodeMirrorEditor = memo( |
| ({ |
| id, |
| doc, |
| debounceScroll = 100, |
| debounceChange = 150, |
| autoFocusOnDocumentChange = false, |
| editable = true, |
| onScroll, |
| onChange, |
| onSave, |
| theme, |
| settings, |
| className = '', |
| }: Props) => { |
| renderLogger.trace('CodeMirrorEditor'); |
|
|
| const [languageCompartment] = useState(new Compartment()); |
|
|
| const containerRef = useRef<HTMLDivElement | null>(null); |
| const viewRef = useRef<EditorView>(); |
| const themeRef = useRef<Theme>(); |
| const docRef = useRef<EditorDocument>(); |
| const editorStatesRef = useRef<EditorStates>(); |
| const onScrollRef = useRef(onScroll); |
| const onChangeRef = useRef(onChange); |
| const onSaveRef = useRef(onSave); |
|
|
| |
| |
| |
| |
| useEffect(() => { |
| onScrollRef.current = onScroll; |
| onChangeRef.current = onChange; |
| onSaveRef.current = onSave; |
| docRef.current = doc; |
| themeRef.current = theme; |
| }); |
|
|
| useEffect(() => { |
| const onUpdate = debounce((update: EditorUpdate) => { |
| onChangeRef.current?.(update); |
| }, debounceChange); |
|
|
| const view = new EditorView({ |
| parent: containerRef.current!, |
| dispatchTransactions(transactions) { |
| const previousSelection = view.state.selection; |
|
|
| view.update(transactions); |
|
|
| const newSelection = view.state.selection; |
|
|
| const selectionChanged = |
| newSelection !== previousSelection && |
| (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection)); |
|
|
| if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) { |
| onUpdate({ |
| selection: view.state.selection, |
| content: view.state.doc.toString(), |
| }); |
|
|
| editorStatesRef.current!.set(docRef.current.filePath, view.state); |
| } |
| }, |
| }); |
|
|
| viewRef.current = view; |
|
|
| return () => { |
| viewRef.current?.destroy(); |
| viewRef.current = undefined; |
| }; |
| }, []); |
|
|
| useEffect(() => { |
| if (!viewRef.current) { |
| return; |
| } |
|
|
| viewRef.current.dispatch({ |
| effects: [reconfigureTheme(theme)], |
| }); |
| }, [theme]); |
|
|
| useEffect(() => { |
| editorStatesRef.current = new Map<string, EditorState>(); |
| }, [id]); |
|
|
| useEffect(() => { |
| const editorStates = editorStatesRef.current!; |
| const view = viewRef.current!; |
| const theme = themeRef.current!; |
|
|
| if (!doc) { |
| const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [ |
| languageCompartment.of([]), |
| ]); |
|
|
| view.setState(state); |
|
|
| setNoDocument(view); |
|
|
| return; |
| } |
|
|
| if (doc.isBinary) { |
| return; |
| } |
|
|
| if (doc.filePath === '') { |
| logger.warn('File path should not be empty'); |
| } |
|
|
| let state = editorStates.get(doc.filePath); |
|
|
| if (!state) { |
| state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [ |
| languageCompartment.of([]), |
| ]); |
|
|
| editorStates.set(doc.filePath, state); |
| } |
|
|
| view.setState(state); |
|
|
| setEditorDocument( |
| view, |
| theme, |
| editable, |
| languageCompartment, |
| autoFocusOnDocumentChange, |
| doc as TextEditorDocument, |
| ); |
| }, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]); |
|
|
| return ( |
| <div className={classNames('relative h-full', className)}> |
| {doc?.isBinary && <BinaryContent />} |
| <div className="h-full overflow-hidden" ref={containerRef} /> |
| </div> |
| ); |
| }, |
| ); |
|
|
| export default CodeMirrorEditor; |
|
|
| CodeMirrorEditor.displayName = 'CodeMirrorEditor'; |
|
|
| function newEditorState( |
| content: string, |
| theme: Theme, |
| settings: EditorSettings | undefined, |
| onScrollRef: MutableRefObject<OnScrollCallback | undefined>, |
| debounceScroll: number, |
| onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>, |
| extensions: Extension[], |
| ) { |
| return EditorState.create({ |
| doc: content, |
| extensions: [ |
| EditorView.domEventHandlers({ |
| scroll: debounce((event, view) => { |
| if (event.target !== view.scrollDOM) { |
| return; |
| } |
|
|
| onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop }); |
| }, debounceScroll), |
| keydown: (event, view) => { |
| if (view.state.readOnly) { |
| view.dispatch({ |
| effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')], |
| }); |
|
|
| return true; |
| } |
|
|
| return false; |
| }, |
| }), |
| getTheme(theme, settings), |
| history(), |
| keymap.of([ |
| ...defaultKeymap, |
| ...historyKeymap, |
| ...searchKeymap, |
| { key: 'Tab', run: acceptCompletion }, |
| { |
| key: 'Mod-s', |
| preventDefault: true, |
| run: () => { |
| onFileSaveRef.current?.(); |
| return true; |
| }, |
| }, |
| indentKeyBinding, |
| ]), |
| indentUnit.of('\t'), |
| autocompletion({ |
| closeOnBlur: false, |
| }), |
| tooltips({ |
| position: 'absolute', |
| parent: document.body, |
| tooltipSpace: (view) => { |
| const rect = view.dom.getBoundingClientRect(); |
|
|
| return { |
| top: rect.top - 50, |
| left: rect.left, |
| bottom: rect.bottom, |
| right: rect.right + 10, |
| }; |
| }, |
| }), |
| closeBrackets(), |
| lineNumbers(), |
| scrollPastEnd(), |
| dropCursor(), |
| drawSelection(), |
| bracketMatching(), |
| EditorState.tabSize.of(settings?.tabSize ?? 2), |
| indentOnInput(), |
| editableTooltipField, |
| editableStateField, |
| EditorState.readOnly.from(editableStateField, (editable) => !editable), |
| highlightActiveLineGutter(), |
| highlightActiveLine(), |
| foldGutter({ |
| markerDOM: (open) => { |
| const icon = document.createElement('div'); |
|
|
| icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`; |
|
|
| return icon; |
| }, |
| }), |
| ...extensions, |
| ], |
| }); |
| } |
|
|
| function setNoDocument(view: EditorView) { |
| view.dispatch({ |
| selection: { anchor: 0 }, |
| changes: { |
| from: 0, |
| to: view.state.doc.length, |
| insert: '', |
| }, |
| }); |
|
|
| view.scrollDOM.scrollTo(0, 0); |
| } |
|
|
| function setEditorDocument( |
| view: EditorView, |
| theme: Theme, |
| editable: boolean, |
| languageCompartment: Compartment, |
| autoFocus: boolean, |
| doc: TextEditorDocument, |
| ) { |
| if (doc.value !== view.state.doc.toString()) { |
| view.dispatch({ |
| selection: { anchor: 0 }, |
| changes: { |
| from: 0, |
| to: view.state.doc.length, |
| insert: doc.value, |
| }, |
| }); |
| } |
|
|
| view.dispatch({ |
| effects: [editableStateEffect.of(editable && !doc.isBinary)], |
| }); |
|
|
| getLanguage(doc.filePath).then((languageSupport) => { |
| if (!languageSupport) { |
| return; |
| } |
|
|
| view.dispatch({ |
| effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)], |
| }); |
|
|
| requestAnimationFrame(() => { |
| const currentLeft = view.scrollDOM.scrollLeft; |
| const currentTop = view.scrollDOM.scrollTop; |
| const newLeft = doc.scroll?.left ?? 0; |
| const newTop = doc.scroll?.top ?? 0; |
|
|
| const needsScrolling = currentLeft !== newLeft || currentTop !== newTop; |
|
|
| if (autoFocus && editable) { |
| if (needsScrolling) { |
| |
| view.scrollDOM.addEventListener( |
| 'scroll', |
| () => { |
| view.focus(); |
| }, |
| { once: true }, |
| ); |
| } else { |
| |
| view.focus(); |
| } |
| } |
|
|
| view.scrollDOM.scrollTo(newLeft, newTop); |
| }); |
| }); |
| } |
|
|
| function getReadOnlyTooltip(state: EditorState) { |
| if (!state.readOnly) { |
| return []; |
| } |
|
|
| return state.selection.ranges |
| .filter((range) => { |
| return range.empty; |
| }) |
| .map((range) => { |
| return { |
| pos: range.head, |
| above: true, |
| strictSide: true, |
| arrow: true, |
| create: () => { |
| const divElement = document.createElement('div'); |
| divElement.className = 'cm-readonly-tooltip'; |
| divElement.textContent = 'Cannot edit file while AI response is being generated'; |
|
|
| return { dom: divElement }; |
| }, |
| }; |
| }); |
| } |
|
|