245 lines
7.9 KiB
TypeScript
Raw Permalink Normal View History

import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter, codeFolding } from "@codemirror/language";
import { Compartment, EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { highlightSelectionMatches } from "@codemirror/search";
import { vim } from "@replit/codemirror-vim";
import { indentationMarkers } from "@replit/codemirror-indentation-markers";
import byMimeType from "./syntax_highlighting.js";
2025-05-11 17:39:10 +03:00
import smartIndentWithTab from "./extensions/custom_tab.js";
2025-05-11 20:19:40 +03:00
import type { ThemeDefinition } from "./color_themes.js";
import { createSearchHighlighter, SearchHighlighter, searchMatchHighlightTheme } from "./find_replace.js";
2025-05-11 20:19:40 +03:00
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
2025-05-10 20:07:53 +03:00
type ContentChangedListener = () => void;
export interface EditorConfig {
parent: HTMLElement;
2025-05-11 11:27:27 +03:00
placeholder?: string;
2025-05-11 12:07:54 +03:00
lineWrapping?: boolean;
vimKeybindings?: boolean;
readOnly?: boolean;
/** Disables some of the nice-to-have features (bracket matching, syntax highlighting, indentation markers) in order to improve performance. */
preferPerformance?: boolean;
2025-05-12 15:47:21 +03:00
tabIndex?: number;
2025-05-10 20:07:53 +03:00
onContentChanged?: ContentChangedListener;
}
2025-05-10 19:10:30 +03:00
export default class CodeMirror extends EditorView {
2025-05-10 20:07:53 +03:00
private config: EditorConfig;
private languageCompartment: Compartment;
private historyCompartment: Compartment;
2025-05-11 20:19:40 +03:00
private themeCompartment: Compartment;
private lineWrappingCompartment: Compartment;
private searchHighlightCompartment: Compartment;
private searchPlugin?: SearchHighlighter | null;
2025-05-10 20:07:53 +03:00
constructor(config: EditorConfig) {
const languageCompartment = new Compartment();
const historyCompartment = new Compartment();
2025-05-11 20:19:40 +03:00
const themeCompartment = new Compartment();
const lineWrappingCompartment = new Compartment();
const searchHighlightCompartment = new Compartment();
let extensions: Extension[] = [];
if (config.vimKeybindings) {
extensions.push(vim());
}
extensions = [
...extensions,
languageCompartment.of([]),
lineWrappingCompartment.of(config.lineWrapping ? EditorView.lineWrapping : []),
searchMatchHighlightTheme,
searchHighlightCompartment.of([]),
highlightActiveLine(),
2025-05-11 11:37:52 +03:00
lineNumbers(),
indentUnit.of(" ".repeat(4)),
keymap.of([
...defaultKeymap,
...historyKeymap,
2025-05-11 17:39:10 +03:00
...smartIndentWithTab
])
]
2025-05-10 20:07:53 +03:00
if (!config.preferPerformance) {
extensions = [
...extensions,
themeCompartment.of([
syntaxHighlighting(defaultHighlightStyle, { fallback: true })
]),
highlightSelectionMatches(),
bracketMatching(),
codeFolding(),
foldGutter(),
indentationMarkers(),
];
}
if (!config.readOnly) {
// Logic specific to editable notes
if (config.placeholder) {
extensions.push(placeholder(config.placeholder));
}
if (config.onContentChanged) {
extensions.push(EditorView.updateListener.of((v) => this.#onDocumentUpdated(v)));
}
extensions.push(historyCompartment.of(history()));
} else {
// Logic specific to read-only notes
extensions.push(EditorState.readOnly.of(true));
2025-05-11 11:27:27 +03:00
}
2025-05-10 19:10:30 +03:00
super({
parent: config.parent,
2025-05-10 20:07:53 +03:00
extensions
2025-05-10 19:10:30 +03:00
});
2025-05-12 15:47:21 +03:00
if (config.tabIndex) {
this.dom.tabIndex = config.tabIndex;
}
2025-05-10 20:07:53 +03:00
this.config = config;
this.languageCompartment = languageCompartment;
this.historyCompartment = historyCompartment;
2025-05-11 20:19:40 +03:00
this.themeCompartment = themeCompartment;
this.lineWrappingCompartment = lineWrappingCompartment;
this.searchHighlightCompartment = searchHighlightCompartment;
2025-05-10 20:07:53 +03:00
}
#onDocumentUpdated(v: ViewUpdate) {
if (v.docChanged) {
this.config.onContentChanged?.();
}
}
getText() {
return this.state.doc.toString();
2025-05-10 19:10:30 +03:00
}
/**
* Returns the currently selected text.
*
* If there are multiple selections, all of them will be concatenated.
*/
getSelectedText() {
return this.state.selection.ranges
.map((range) => this.state.sliceDoc(range.from, range.to))
.join("");
}
setText(content: string) {
this.dispatch({
changes: {
from: 0,
to: this.state.doc.length,
insert: content || "",
}
})
}
2025-05-11 20:19:40 +03:00
async setTheme(theme: ThemeDefinition) {
const extension = await theme.load();
this.dispatch({
effects: [ this.themeCompartment.reconfigure([ extension ]) ]
});
2025-05-11 20:19:40 +03:00
}
setLineWrapping(wrapping: boolean) {
this.dispatch({
effects: [ this.lineWrappingCompartment.reconfigure(wrapping ? EditorView.lineWrapping : []) ]
});
}
/**
* Clears the history of undo/redo. Generally useful when changing to a new document.
*/
clearHistory() {
if (this.config.readOnly) {
return;
}
this.dispatch({
effects: [ this.historyCompartment.reconfigure([]) ]
});
this.dispatch({
effects: [ this.historyCompartment.reconfigure(history())]
});
}
scrollToEnd() {
const endPos = this.state.doc.length;
this.dispatch({
selection: EditorSelection.cursor(endPos),
effects: EditorView.scrollIntoView(endPos, { y: "end" }),
scrollIntoView: true
});
}
async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) {
const plugin = createSearchHighlighter();
this.dispatch({
effects: this.searchHighlightCompartment.reconfigure(plugin)
});
2025-05-12 20:26:48 +03:00
// Wait for the plugin to activate in the next render cycle
await new Promise(requestAnimationFrame);
2025-05-12 20:26:48 +03:00
const instance = this.plugin(plugin);
instance?.searchFor(searchTerm, matchCase, wholeWord);
this.searchPlugin = instance;
return {
2025-05-12 20:43:30 +03:00
totalFound: instance?.totalFound ?? 0,
currentFound: instance?.currentFound ?? 0
}
}
async findNext(direction: number, currentFound: number, nextFound: number) {
this.searchPlugin?.scrollToMatch(nextFound);
}
async replace(replaceText: string) {
this.searchPlugin?.replaceActiveMatch(replaceText);
}
async replaceAll(replaceText: string) {
this.searchPlugin?.replaceAll(replaceText);
}
2025-05-12 22:17:10 +03:00
cleanSearch() {
if (this.searchPlugin) {
this.dispatch({
effects: this.searchHighlightCompartment.reconfigure([])
});
this.searchPlugin = null;
}
}
async setMimeType(mime: string) {
2025-05-11 15:18:42 +03:00
let newExtension: Extension[] = [];
const correspondingSyntax = byMimeType[mime];
if (correspondingSyntax) {
2025-05-11 10:54:15 +03:00
const resolvedSyntax = await correspondingSyntax();
if ("token" in resolvedSyntax) {
const extension = StreamLanguage.define(resolvedSyntax);
newExtension.push(extension);
2025-05-11 15:18:42 +03:00
} else if (Array.isArray(resolvedSyntax)) {
newExtension = [ ...newExtension, ...resolvedSyntax ];
2025-05-11 10:54:15 +03:00
} else {
newExtension.push(resolvedSyntax);
2025-05-11 10:54:15 +03:00
}
}
this.dispatch({
effects: this.languageCompartment.reconfigure(newExtension)
});
}
2025-05-10 19:10:30 +03:00
}