|
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 }; |
|
}, |
|
}; |
|
}); |
|
} |
|
|