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();