import {
EditorState,
StateField,
StateEffect,
Range,
Extension,
} from "@codemirror/state";
import { Decoration, EditorView, keymap } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
import rainbowBrackets from "rainbowbrackets";
import { basicSetup } from "codemirror";
import { ModalInstance, ModalSingleton } from ".";
import { EditorSave, Failure, SOUNDS } from "../config";
export interface CodeEditorError extends Error {
location: {
start: { offset: number };
end: { offset: number };
};
}
export type CodeConsumer = (code: string) => {
consumed?: boolean;
error?: CodeEditorError;
};
interface CodeEditorState {
view: EditorView;
editorElement: HTMLElement;
errorElement: HTMLElement;
resultElement: HTMLElement;
closeButton: HTMLButtonElement;
codeConsumer: CodeConsumer;
}
export class CodeEditorInstance {
constructor(
private modalInstance: ModalInstance = ModalSingleton,
private codeEditorState: CodeEditorState | null = null,
) {}
public close() {
if (!this.codeEditorState) {
return;
}
this.codeEditorState.view.destroy();
this.codeEditorState = null;
SOUNDS.get(EditorSave.name)!.play();
}
public open(
initCode: string,
codeConsumer: CodeEditorState["codeConsumer"],
readonly: boolean = false,
initResult: { data?: string; error?: string } = {},
) {
if (this.codeEditorState) {
throw new Error("code editor instance is already owned.");
}
const modalContent = `
`;
this.modalInstance.open(modalContent);
const startState = EditorState.create({
doc: initCode,
extensions: [
basicSetup,
keymap.of(defaultKeymap),
rainbowBrackets(),
highlightExtension,
FontSizeThemeExtension,
EditorState.readOnly.of(readonly),
],
});
const codeBox = document.getElementById("code")!;
const errorElement = document.getElementById("error")!;
const resultElement = document.getElementById("result")!;
const closeButton = document.getElementById(
"close-modal",
) as HTMLButtonElement;
closeButton.addEventListener("click", () => this.onSave());
const editorView = new EditorView({
state: startState,
parent: codeBox,
});
editorView.focus();
this.codeEditorState = {
view: editorView,
editorElement: codeBox,
errorElement,
resultElement,
closeButton,
codeConsumer,
};
this.setResult(initResult);
}
private refreshCodeEditorText(text: string) {
if (!this.codeEditorState) {
return;
}
const { view } = this.codeEditorState;
view.dispatch({
changes: {
from: 0,
to: text.length,
insert: "",
},
});
view.dispatch({
changes: {
from: 0,
to: 0,
insert: text,
},
});
}
private onSave() {
if (!this.codeEditorState) {
throw new Error();
}
const { view } = this.codeEditorState;
const code = view.state.doc.toString();
this.refreshCodeEditorText(code);
const valid = this.codeEditorState.codeConsumer(code);
if (!valid.error) {
return;
}
const {
location: {
start: { offset: start },
end: { offset: end },
},
message,
} = valid.error;
view.dispatch({
effects: highlightEffect.of([
syntaxErrorDecoration.range(start === end ? start - 1 : start, end),
]),
});
this.setResult({ error: message });
SOUNDS.get(Failure.name)!.play();
}
private setResult(result: { data?: string; error?: string }) {
if (!this.codeEditorState) {
return;
}
this.codeEditorState.resultElement.innerText = result.data ?? "";
this.codeEditorState.errorElement.innerText = result.error ?? "";
}
}
const highlightEffect = StateEffect.define[]>();
const highlightExtension = StateField.define({
create() {
return Decoration.none;
},
update(value, transaction) {
value = value.map(transaction.changes);
for (let effect of transaction.effects) {
if (effect.is(highlightEffect))
value = value.update({ add: effect.value, sort: true });
}
return value;
},
provide: (f) => EditorView.decorations.from(f),
});
const FontSizeTheme = EditorView.theme({
$: {
fontSize: "14pt",
},
});
const FontSizeThemeExtension: Extension = [FontSizeTheme];
const syntaxErrorDecoration = Decoration.mark({
class: "syntax-error",
});
export const CodeEditorSingleton = new CodeEditorInstance();