240 lines
6.3 KiB
TypeScript
240 lines
6.3 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import {
|
|
Decoration,
|
|
EditorView,
|
|
keymap,
|
|
lineNumbers,
|
|
placeholder as cmPlaceholder,
|
|
drawSelection,
|
|
} from "@codemirror/view";
|
|
import { Compartment, EditorState, type Extension } from "@codemirror/state";
|
|
import {
|
|
defaultKeymap,
|
|
history,
|
|
historyKeymap,
|
|
redo,
|
|
} from "@codemirror/commands";
|
|
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
|
import { tags } from "@lezer/highlight";
|
|
|
|
import type { Theme } from "@excalidraw/element/types";
|
|
|
|
import { mermaidLite } from "./mermaid-lang-lite";
|
|
|
|
export interface CodeMirrorEditorProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onKeyboardSubmit?: () => void;
|
|
placeholder?: string;
|
|
theme: Theme;
|
|
errorLine?: number | null;
|
|
}
|
|
|
|
// ---- Dark theme ----
|
|
|
|
const darkTheme = EditorView.theme(
|
|
{
|
|
"&": {
|
|
backgroundColor: "#1e1e1e",
|
|
color: "#d4d4d4",
|
|
},
|
|
".cm-content": { caretColor: "#fff" },
|
|
".cm-cursor": { borderLeftColor: "#fff" },
|
|
".cm-gutters": {
|
|
backgroundColor: "#1e1e1e",
|
|
color: "#858585",
|
|
border: "none",
|
|
},
|
|
".cm-activeLineGutter": { backgroundColor: "#2a2a2a" },
|
|
".cm-activeLine": { backgroundColor: "#2a2a2a" },
|
|
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" },
|
|
},
|
|
{ dark: true },
|
|
);
|
|
|
|
const darkHighlight = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "#569cd6" },
|
|
{ tag: tags.string, color: "#ce9178" },
|
|
{ tag: tags.comment, color: "#6a9955" },
|
|
{ tag: tags.number, color: "#b5cea8" },
|
|
{ tag: tags.operator, color: "#d4d4d4" },
|
|
{ tag: tags.punctuation, color: "#d4d4d4" },
|
|
{ tag: tags.variableName, color: "#9cdcfe" },
|
|
{ tag: tags.bracket, color: "#ffd700" },
|
|
]);
|
|
|
|
// ---- Light theme ----
|
|
|
|
const lightTheme = EditorView.theme({
|
|
"&": {
|
|
backgroundColor: "#ffffff",
|
|
color: "#1e1e1e",
|
|
},
|
|
".cm-content": { caretColor: "#000" },
|
|
".cm-cursor": { borderLeftColor: "#000" },
|
|
".cm-gutters": {
|
|
backgroundColor: "#fff",
|
|
color: "#999",
|
|
border: "none",
|
|
},
|
|
".cm-activeLineGutter": { backgroundColor: "#e8e8e8" },
|
|
".cm-activeLine": { backgroundColor: "#e8e8e8" },
|
|
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" },
|
|
});
|
|
|
|
const lightHighlight = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "#0000ff" },
|
|
{ tag: tags.string, color: "#a31515" },
|
|
{ tag: tags.comment, color: "#008000" },
|
|
{ tag: tags.number, color: "#098658" },
|
|
{ tag: tags.operator, color: "#1e1e1e" },
|
|
{ tag: tags.punctuation, color: "#1e1e1e" },
|
|
{ tag: tags.variableName, color: "#001080" },
|
|
{ tag: tags.bracket, color: "#af00db" },
|
|
]);
|
|
|
|
// ---- Error line decoration ----
|
|
|
|
const errorLineDeco = Decoration.line({ class: "cm-errorLine" });
|
|
|
|
const getErrorLineExtension = (
|
|
errorLine: number | null | undefined,
|
|
doc: { line(n: number): { from: number }; lines: number },
|
|
): Extension => {
|
|
if (!errorLine || errorLine < 1 || errorLine > doc.lines) {
|
|
return EditorView.decorations.of(Decoration.none);
|
|
}
|
|
const line = doc.line(errorLine);
|
|
return EditorView.decorations.of(
|
|
Decoration.set([errorLineDeco.range(line.from)]),
|
|
);
|
|
};
|
|
|
|
// ---- Helpers ----
|
|
|
|
const getThemeExtensions = (theme: Theme) => {
|
|
if (theme === "dark") {
|
|
return [darkTheme, syntaxHighlighting(darkHighlight)];
|
|
}
|
|
return [lightTheme, syntaxHighlighting(lightHighlight)];
|
|
};
|
|
|
|
const CodeMirrorEditor = ({
|
|
value,
|
|
onChange,
|
|
onKeyboardSubmit,
|
|
placeholder,
|
|
theme,
|
|
errorLine,
|
|
}: CodeMirrorEditorProps) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const viewRef = useRef<EditorView | null>(null);
|
|
const onChangeRef = useRef(onChange);
|
|
const onKeyboardSubmitRef = useRef(onKeyboardSubmit);
|
|
const themeCompartmentRef = useRef(new Compartment());
|
|
const errorLineCompartmentRef = useRef(new Compartment());
|
|
|
|
onChangeRef.current = onChange;
|
|
onKeyboardSubmitRef.current = onKeyboardSubmit;
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) {
|
|
return;
|
|
}
|
|
|
|
const themeCompartment = themeCompartmentRef.current;
|
|
|
|
const view = new EditorView({
|
|
state: EditorState.create({
|
|
doc: value,
|
|
extensions: [
|
|
keymap.of([
|
|
{
|
|
key: "Mod-Enter",
|
|
run: () => {
|
|
onKeyboardSubmitRef.current?.();
|
|
return true;
|
|
},
|
|
},
|
|
// historyKeymap binds Mod-Shift-z only on Mac; add it for all platforms
|
|
{ key: "Mod-Shift-z", run: redo, preventDefault: true },
|
|
]),
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
onChangeRef.current(update.state.doc.toString());
|
|
}
|
|
}),
|
|
history(),
|
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
lineNumbers(),
|
|
EditorView.lineWrapping,
|
|
themeCompartment.of(getThemeExtensions(theme)),
|
|
errorLineCompartmentRef.current.of([]),
|
|
mermaidLite(),
|
|
drawSelection({ drawRangeCursor: true }),
|
|
...(placeholder ? [cmPlaceholder(placeholder)] : []),
|
|
],
|
|
}),
|
|
parent: containerRef.current,
|
|
});
|
|
|
|
viewRef.current = view;
|
|
view.focus();
|
|
|
|
return () => {
|
|
view.destroy();
|
|
viewRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Swap theme dynamically via compartment
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) {
|
|
return;
|
|
}
|
|
view.dispatch({
|
|
effects: themeCompartmentRef.current.reconfigure(
|
|
getThemeExtensions(theme),
|
|
),
|
|
});
|
|
}, [theme]);
|
|
|
|
// Update error line highlight
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) {
|
|
return;
|
|
}
|
|
view.dispatch({
|
|
effects: errorLineCompartmentRef.current.reconfigure(
|
|
getErrorLineExtension(errorLine, view.state.doc),
|
|
),
|
|
});
|
|
}, [errorLine]);
|
|
|
|
// Sync external value changes into EditorView
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) {
|
|
return;
|
|
}
|
|
const currentDoc = view.state.doc.toString();
|
|
if (value !== currentDoc) {
|
|
view.dispatch({
|
|
changes: { from: 0, to: currentDoc.length, insert: value },
|
|
});
|
|
}
|
|
}, [value]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="ttd-dialog-input ttd-dialog-input--codemirror"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default CodeMirrorEditor;
|