diff options
Diffstat (limited to 'src/engine/utils/CodeEditor.ts')
-rw-r--r-- | src/engine/utils/CodeEditor.ts | 200 |
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(); |