import $ from 'jquery'; import { debounce } from 'lodash'; import { nanoid } from 'nanoid'; import React from 'react'; import ReactDOM from 'react-dom/client'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import { getTheme, i18n } from 'vj/utils'; import uploadFiles from '../upload'; interface MonacoOptions { language?: string; onChange?: (val: string) => any; theme?: string; model?: string; autoResize?: boolean; autoLayout?: boolean; value?: string; hide?: string[]; lineNumbers?: 'on' | 'off' | 'relative' | 'interval'; } type Options = MonacoOptions; export default class Editor extends DOMAttachedObject { static DOMAttachKey = 'vjEditorInstance'; model: import('../monaco').default.editor.IModel; editor: import('../monaco').default.editor.IStandaloneCodeEditor; markdownEditor: any; valueCache?: string; setMarkdownEditorValue?: (v: string) => void; reactRoot?: ReactDOM.Root; isValid: boolean; constructor($dom, public options: Options = {}) { super($dom); if (UserContext.preferredEditorType === 'monaco') this.initMonaco(); else if (options.language && options.language !== 'markdown') this.initMonaco(); else this.initMarkdownEditor(); } async initMonaco() { const { load } = await import('vj/components/monaco/loader'); const { onChange, language = 'markdown', theme = `vs-${getTheme()}`, model = `file://model-${Math.random().toString(16)}`, autoResize = true, autoLayout = true, hide = [], lineNumbers = 'on', } = this.options; const { monaco, registerAction } = await load([language]); const { $dom } = this; const hasFocus = $dom.is(':focus') || $dom.hasClass('autofocus'); const origin = $dom.get(0); const ele = document.createElement('div'); $(ele).width('100%').addClass('textbox'); if (!autoResize && $dom.height()) $(ele).height($dom.height()); $dom.hide(); origin.parentElement.appendChild(ele); const value = this.options.value || $dom.val(); this.model = typeof model === 'string' ? monaco.editor.getModel(monaco.Uri.parse(model)) || monaco.editor.createModel(value, language === 'auto' ? undefined : language, monaco.Uri.parse(model)) : model; if (!this.options.model) this.model.setValue(value); const cfg: import('../monaco').default.editor.IStandaloneEditorConstructionOptions = { theme, lineNumbers, glyphMargin: true, lightbulb: { enabled: monaco.editor.ShowLightbulbIconMode.On }, model: this.model, minimap: { enabled: false }, hideCursorInOverviewRuler: true, overviewRulerLanes: 0, overviewRulerBorder: false, fontFamily: UserContext.codeFontFamily, fontLigatures: '', unicodeHighlight: { ambiguousCharacters: language !== 'markdown', }, }; if (autoLayout) cfg.automaticLayout = true; let prevHeight = 0; const updateEditorHeight = () => { const editorElement = this.editor.getDomNode(); if (!editorElement) return; const lineHeight = this.editor.getOption(monaco.editor.EditorOption.lineHeight); const lineCount = this.editor.getModel()?.getLineCount() || 1; let height = this.editor.getTopForLineNumber(lineCount + 1) + lineHeight; if (prevHeight !== height) { if (window.innerHeight * 1.5 < height) { height = window.innerHeight; this.editor.updateOptions({ scrollbar: { vertical: 'auto', horizontal: 'auto', handleMouseWheel: true, }, }); } else { this.editor.updateOptions({ scrollbar: { vertical: 'hidden', horizontal: 'hidden', handleMouseWheel: false, }, }); } prevHeight = height; editorElement.style.height = `${height}px`; this.editor.layout(); } }; if (autoResize) { cfg.wordWrap = 'bounded'; cfg.scrollBeyondLastLine = false; } this.editor = monaco.editor.create(ele, cfg); if (hide.length) { const ranges = []; for (const text of hide) { const found = this.model.findMatches(text, true, false, true, '', true); ranges.push(...found.map((i) => i.range)); } this.editor.deltaDecorations([], ranges.map((range) => ({ range, options: { inlineClassName: 'decoration-hide' } }))); } registerAction(this.editor, this.model, this.$dom); if (autoResize) { this.editor.onDidChangeModelDecorations(() => { updateEditorHeight(); // typing requestAnimationFrame(updateEditorHeight); // folding }); } this.editor.onDidChangeModelContent(() => { const val = this.editor.getValue({ lineEnding: '\n', preserveBOM: false }); $dom.val(val); $dom.text(val); if (onChange) onChange(val); }); this.isValid = true; if (hasFocus) this.focus(); if (autoResize) updateEditorHeight(); // @ts-ignore window.model = this.model; // @ts-ignore window.editor = this.editor; } async initMarkdownEditor() { const pagename = document.documentElement.getAttribute('data-page'); const isProblemPage = ['problem_create', 'problem_edit'].includes(pagename); const isProblemEdit = pagename === 'problem_edit'; const that = this; const { $dom } = this; const hasFocus = $dom.is(':focus') || $dom.hasClass('autofocus'); const origin = $dom.get(0); const ele = document.createElement('div'); const value = $dom.val(); const { onChange } = this.options; const { MdEditor } = await import('./mdeditor'); const debouncedTrigger = debounce(() => { $(document.querySelector('.md-editor-preview')).trigger('vjContentNew'); }, 500, { trailing: true }); const renderCallback = (ref) => { this.markdownEditor = ref; setTimeout(debouncedTrigger, 200); }; function EditorComponent() { const [val, setVal] = React.useState(value); that.setMarkdownEditorValue = setVal; return { that.valueCache = v; setVal(v); $dom.val(v); $dom.text(v); onChange?.(v); setTimeout(debouncedTrigger, 100); }} onUploadImg={async (files, callback) => { let ext: string; const matches = files[0].type.match(/^image\/(png|jpg|jpeg|gif)$/i); if (matches) { [, ext] = matches; } else if (files[0].type === 'application/x-zip-compressed') { ext = 'zip'; } if (!ext) return i18n('No Supported file type.'); const filename = `${nanoid()}.${ext}`; await uploadFiles(isProblemEdit ? './files' : '/file', [files[0]], { type: isProblemEdit ? 'additional_file' : undefined, filenameCallback: () => filename, }).then(() => { callback([`${isProblemPage ? 'file://' : `/file/${UserContext._id}/`}${filename}`]); }).catch(() => { callback([]); }); return null; }} />; } this.valueCache = value; this.reactRoot = ReactDOM.createRoot(ele); this.reactRoot.render(); $dom.hide(); origin.parentElement.appendChild(ele); this.isValid = true; if (hasFocus) this.focus(); } destroy() { this.detach(); if (this.reactRoot) this.reactRoot.unmount(); else if (this.editor?.dispose) this.editor.dispose(); } ensureValid() { if (!this.isValid) throw new Error('Editor is not loaded'); } value(val?: string) { this.ensureValid(); if (typeof val === 'string') { if (this.editor) return this.editor.setValue(val); this.setMarkdownEditorValue?.(val); this.markdownEditor?.resetHistory?.(); } if (this.editor) return this.editor.getValue({ lineEnding: '\n', preserveBOM: false }); return this.valueCache; } focus() { this.ensureValid(); if (!this.editor || !this.model) return; this.editor.focus(); const range = this.model.getFullModelRange(); this.editor.setPosition({ lineNumber: range.endLineNumber, column: range.endColumn }); } }