2025-05-11 18:41:10 +03:00
|
|
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
2025-05-12 21:21:46 +03:00
|
|
|
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
|
2025-05-31 11:44:10 +03:00
|
|
|
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter, codeFolding } from "@codemirror/language";
|
2025-05-12 12:37:38 +03:00
|
|
|
import { Compartment, EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
2025-05-11 12:10:28 +03:00
|
|
|
import { highlightSelectionMatches } from "@codemirror/search";
|
2025-05-11 17:04:15 +03:00
|
|
|
import { vim } from "@replit/codemirror-vim";
|
2025-05-13 14:32:50 +03:00
|
|
|
import { indentationMarkers } from "@replit/codemirror-indentation-markers";
|
2025-05-10 23:34:23 +03:00
|
|
|
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";
|
2025-05-12 21:21:46 +03:00
|
|
|
import { createSearchHighlighter, SearchHighlighter, searchMatchHighlightTheme } from "./find_replace.js";
|
2025-05-11 20:19:40 +03:00
|
|
|
|
2025-05-11 21:18:14 +03:00
|
|
|
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
|
2025-05-10 20:07:53 +03:00
|
|
|
|
|
|
|
type ContentChangedListener = () => void;
|
|
|
|
|
2025-05-11 18:41:10 +03:00
|
|
|
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;
|
2025-05-11 17:04:15 +03:00
|
|
|
vimKeybindings?: boolean;
|
2025-05-11 18:42:56 +03:00
|
|
|
readOnly?: boolean;
|
2025-05-15 15:32:09 +03:00
|
|
|
/** 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;
|
2025-05-10 23:34:23 +03:00
|
|
|
private languageCompartment: Compartment;
|
2025-05-11 12:23:09 +03:00
|
|
|
private historyCompartment: Compartment;
|
2025-05-11 20:19:40 +03:00
|
|
|
private themeCompartment: Compartment;
|
2025-05-12 00:23:47 +03:00
|
|
|
private lineWrappingCompartment: Compartment;
|
2025-05-12 18:47:50 +03:00
|
|
|
private searchHighlightCompartment: Compartment;
|
2025-05-12 21:21:46 +03:00
|
|
|
private searchPlugin?: SearchHighlighter | null;
|
2025-05-10 20:07:53 +03:00
|
|
|
|
|
|
|
constructor(config: EditorConfig) {
|
2025-05-10 23:34:23 +03:00
|
|
|
const languageCompartment = new Compartment();
|
2025-05-11 12:23:09 +03:00
|
|
|
const historyCompartment = new Compartment();
|
2025-05-11 20:19:40 +03:00
|
|
|
const themeCompartment = new Compartment();
|
2025-05-12 00:23:47 +03:00
|
|
|
const lineWrappingCompartment = new Compartment();
|
2025-05-12 18:47:50 +03:00
|
|
|
const searchHighlightCompartment = new Compartment();
|
2025-05-11 12:23:09 +03:00
|
|
|
|
2025-05-11 17:04:15 +03:00
|
|
|
let extensions: Extension[] = [];
|
|
|
|
|
|
|
|
if (config.vimKeybindings) {
|
|
|
|
extensions.push(vim());
|
|
|
|
}
|
|
|
|
|
|
|
|
extensions = [
|
|
|
|
...extensions,
|
2025-05-10 23:34:23 +03:00
|
|
|
languageCompartment.of([]),
|
2025-05-12 00:23:47 +03:00
|
|
|
lineWrappingCompartment.of(config.lineWrapping ? EditorView.lineWrapping : []),
|
2025-05-12 18:47:50 +03:00
|
|
|
searchMatchHighlightTheme,
|
|
|
|
searchHighlightCompartment.of([]),
|
2025-05-11 12:10:28 +03:00
|
|
|
highlightActiveLine(),
|
2025-05-11 11:37:52 +03:00
|
|
|
lineNumbers(),
|
2025-05-11 12:19:39 +03:00
|
|
|
indentUnit.of(" ".repeat(4)),
|
|
|
|
keymap.of([
|
|
|
|
...defaultKeymap,
|
|
|
|
...historyKeymap,
|
2025-05-11 17:39:10 +03:00
|
|
|
...smartIndentWithTab
|
2025-05-11 17:04:15 +03:00
|
|
|
])
|
|
|
|
]
|
2025-05-10 20:07:53 +03:00
|
|
|
|
2025-05-15 15:32:09 +03:00
|
|
|
if (!config.preferPerformance) {
|
|
|
|
extensions = [
|
|
|
|
...extensions,
|
|
|
|
themeCompartment.of([
|
|
|
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true })
|
|
|
|
]),
|
|
|
|
highlightSelectionMatches(),
|
|
|
|
bracketMatching(),
|
2025-05-31 11:44:10 +03:00
|
|
|
codeFolding(),
|
2025-05-15 15:32:09 +03:00
|
|
|
foldGutter(),
|
|
|
|
indentationMarkers(),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2025-05-11 18:51:44 +03:00
|
|
|
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)));
|
|
|
|
}
|
2025-05-11 18:42:56 +03:00
|
|
|
|
2025-05-11 18:51:44 +03:00
|
|
|
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({
|
2025-05-11 18:41:10 +03:00
|
|
|
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;
|
2025-05-10 23:34:23 +03:00
|
|
|
this.languageCompartment = languageCompartment;
|
2025-05-11 12:23:09 +03:00
|
|
|
this.historyCompartment = historyCompartment;
|
2025-05-11 20:19:40 +03:00
|
|
|
this.themeCompartment = themeCompartment;
|
2025-05-12 00:23:47 +03:00
|
|
|
this.lineWrappingCompartment = lineWrappingCompartment;
|
2025-05-12 18:47:50 +03:00
|
|
|
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
|
|
|
}
|
2025-05-10 19:22:57 +03:00
|
|
|
|
2025-05-12 18:24:36 +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("");
|
|
|
|
}
|
|
|
|
|
2025-05-10 19:22:57 +03:00
|
|
|
setText(content: string) {
|
|
|
|
this.dispatch({
|
|
|
|
changes: {
|
|
|
|
from: 0,
|
|
|
|
to: this.state.doc.length,
|
|
|
|
insert: content || "",
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
2025-05-10 23:34:23 +03:00
|
|
|
|
2025-05-11 20:19:40 +03:00
|
|
|
async setTheme(theme: ThemeDefinition) {
|
2025-05-11 21:18:14 +03:00
|
|
|
const extension = await theme.load();
|
|
|
|
this.dispatch({
|
|
|
|
effects: [ this.themeCompartment.reconfigure([ extension ]) ]
|
|
|
|
});
|
2025-05-11 20:19:40 +03:00
|
|
|
}
|
|
|
|
|
2025-05-12 00:23:47 +03:00
|
|
|
setLineWrapping(wrapping: boolean) {
|
|
|
|
this.dispatch({
|
|
|
|
effects: [ this.lineWrappingCompartment.reconfigure(wrapping ? EditorView.lineWrapping : []) ]
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-05-11 12:23:09 +03:00
|
|
|
/**
|
|
|
|
* Clears the history of undo/redo. Generally useful when changing to a new document.
|
|
|
|
*/
|
|
|
|
clearHistory() {
|
2025-05-11 18:51:44 +03:00
|
|
|
if (this.config.readOnly) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-05-11 12:23:09 +03:00
|
|
|
this.dispatch({
|
|
|
|
effects: [ this.historyCompartment.reconfigure([]) ]
|
|
|
|
});
|
|
|
|
this.dispatch({
|
|
|
|
effects: [ this.historyCompartment.reconfigure(history())]
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-05-12 12:37:38 +03:00
|
|
|
scrollToEnd() {
|
|
|
|
const endPos = this.state.doc.length;
|
|
|
|
this.dispatch({
|
|
|
|
selection: EditorSelection.cursor(endPos),
|
|
|
|
effects: EditorView.scrollIntoView(endPos, { y: "end" }),
|
|
|
|
scrollIntoView: true
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-05-12 18:47:50 +03:00
|
|
|
async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) {
|
2025-05-12 20:59:46 +03:00
|
|
|
const plugin = createSearchHighlighter();
|
2025-05-12 18:47:50 +03:00
|
|
|
this.dispatch({
|
|
|
|
effects: this.searchHighlightCompartment.reconfigure(plugin)
|
|
|
|
});
|
2025-05-12 20:26:48 +03:00
|
|
|
|
2025-05-12 19:35:07 +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);
|
2025-05-12 20:59:46 +03:00
|
|
|
instance?.searchFor(searchTerm, matchCase, wholeWord);
|
2025-05-12 21:21:46 +03:00
|
|
|
this.searchPlugin = instance;
|
2025-05-12 19:35:07 +03:00
|
|
|
|
|
|
|
return {
|
2025-05-12 20:43:30 +03:00
|
|
|
totalFound: instance?.totalFound ?? 0,
|
|
|
|
currentFound: instance?.currentFound ?? 0
|
2025-05-12 19:35:07 +03:00
|
|
|
}
|
2025-05-12 18:47:50 +03:00
|
|
|
}
|
|
|
|
|
2025-05-12 21:21:46 +03:00
|
|
|
async findNext(direction: number, currentFound: number, nextFound: number) {
|
|
|
|
this.searchPlugin?.scrollToMatch(nextFound);
|
|
|
|
}
|
|
|
|
|
2025-05-12 23:52:41 +03:00
|
|
|
async replace(replaceText: string) {
|
|
|
|
this.searchPlugin?.replaceActiveMatch(replaceText);
|
|
|
|
}
|
|
|
|
|
2025-05-13 09:42:31 +03:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-05-10 23:34:23 +03:00
|
|
|
async setMimeType(mime: string) {
|
2025-05-11 15:18:42 +03:00
|
|
|
let newExtension: Extension[] = [];
|
2025-05-10 23:34:23 +03:00
|
|
|
|
|
|
|
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 {
|
2025-05-11 13:09:36 +03:00
|
|
|
newExtension.push(resolvedSyntax);
|
2025-05-11 10:54:15 +03:00
|
|
|
}
|
2025-05-10 23:34:23 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
this.dispatch({
|
|
|
|
effects: this.languageCompartment.reconfigure(newExtension)
|
|
|
|
});
|
|
|
|
}
|
2025-05-10 19:10:30 +03:00
|
|
|
}
|