import type { ValidationState } from '../../common/static-wrappers/interfaces/validation-interface'; import { markRaw } from 'vue'; import { Prop, toNative, Watch } from 'vue-facing-decorator'; import TsxComponent, { Component } from '../../app/vuetsx'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import LoadingIndicator from '../loading-indicator'; const MONACO_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.20.0/min'; let monacoLoadPromise: Promise | null = null; const loadMonaco = (): Promise => { if ((window as any).monaco) { return Promise.resolve(); } if (monacoLoadPromise) { return monacoLoadPromise; } monacoLoadPromise = new Promise((resolve, reject) => { const loaderScript = document.createElement('script'); loaderScript.src = `${MONACO_CDN}/vs/loader.js`; loaderScript.onload = () => { const require = (window as any).require; require.config({ paths: { vs: `${MONACO_CDN}/vs` } }); require( ['vs/editor/editor.main'], () => { resolve(); }, (err: any) => { reject(err); }, ); }; loaderScript.onerror = () => { monacoLoadPromise = null; reject(new Error('Failed to load Monaco Editor')); }; document.head.appendChild(loaderScript); }); return monacoLoadPromise; }; interface MonacoEditorArgs { value?: string; changed?: (v: string) => void; language?: string; height?: string; /** Invoked when the Monaco CDN load fails — callers can fall back to a plain editor. */ loadFailed?: () => void; /** Extra monaco editor options merged into the defaults (e.g. minimap, wordWrap). */ options?: Record; /** Validation state of the bound value (the base TsxComponent prop — declared here so TSX callers can pass it). */ validationState?: ValidationState; } @Component export class MonacoEditorComponent extends TsxComponent implements MonacoEditorArgs { @Prop() value: string; @Prop() language: string; @Prop() height!: string; @Prop() changed: (newValue: string) => void; @Prop() loadFailed: () => void; @Prop() options: Record; editor: any; monacoLoading: boolean = false; raiseChangeEvent() { this.populateValidationDeclaration(); if (this.changed != null) { // NEVER assign `this.value` here: `value` is a prop and the readonly // proxy trap THROWS in Vue 3, which would kill the relay before // `changed` fires. The parent feeds the new value back via the prop. this.changed(this._getValue()); } } mounted() { if ((window as any).monaco) { this.initMonaco(); } else { this.monacoLoading = true; loadMonaco().then(() => { this.monacoLoading = false; this.initMonaco(); }).catch(() => { // CDN unreachable / loader error: stop the spinner and let the // caller degrade (e.g. swap in a plain textarea). this.monacoLoading = false; if (this.loadFailed != null) { this.loadFailed(); } }); } } getOptions() { return { ...(this.options || {}) }; } initMonaco() { // markRaw is MANDATORY: `editor` is a reactive class field, so without it // Vue wraps the monaco instance (and every object reached through it) in // reactive proxies. Monaco's piece-tree walks compare node identity // against sentinels — proxied nodes never match, so the first // `getValue()` inside a change event spins forever and freezes the tab. this.editor = markRaw((window as any).monaco.editor.create((this.$el as HTMLElement), { value: this.value, language: this.language, theme: 'vs', ...this.getOptions(), })); this._editorMounted(this.editor); }; refreshLayout() { this.editor && this.$nextTick(() => { this.editor.layout(); }); } _getEditor() { if (!this.editor) { return null; } return this.editor; }; _setValue(value) { const editor = this._getEditor(); if (editor) { return editor.setValue(value); } }; _getValue() { const editor = this._getEditor(); if (!editor) { return ''; } return editor.getValue(); }; _editorMounted(editor) { editor.onDidChangeModelContent((event) => { this.raiseChangeEvent(); }); }; @Watch('value') onValueChanged(val: string, oldVal: string) { if (val != oldVal) { const currentVal = this._getValue(); if (currentVal != val) { this._setValue(val); } } } render(h) { return (
{this.monacoLoading && }
); } } const MonacoEditor = toNative(MonacoEditorComponent); export default MonacoEditor;