summaryrefslogtreecommitdiff
path: root/src/engine/utils/CodeEditor.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/engine/utils/CodeEditor.ts')
-rw-r--r--src/engine/utils/CodeEditor.ts200
1 files changed, 200 insertions, 0 deletions
diff --git a/src/engine/utils/CodeEditor.ts b/src/engine/utils/CodeEditor.ts
new file mode 100644
index 0000000..e13d1d4
--- /dev/null
+++ b/src/engine/utils/CodeEditor.ts
@@ -0,0 +1,200 @@
+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, ModalOpen, 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 = `
+ <div class='code'>
+ <div id='code'></div>
+ <br>
+ <p id='error' class='error'></p>
+ <p id='result' class='result'></p>
+ <button id='close-modal'>Done</button>
+ </div>
+ `;
+ 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<Range<Decoration>[]>();
+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();