mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 17:36:08 +08:00
294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
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 pageUploadConfig: Record<string, { endpoint: string, type?: string }> = {
|
|
problem_edit: { endpoint: './files', type: 'additional_file' },
|
|
homework_edit: { endpoint: './file' },
|
|
contest_edit: { endpoint: './management', type: 'public' },
|
|
};
|
|
const uploadCfg = pageUploadConfig[pagename];
|
|
const useFileProtocol = !!uploadCfg || ['problem_create', 'homework_create', 'contest_create'].includes(pagename);
|
|
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 <MdEditor
|
|
className="textbox"
|
|
autoFocus={hasFocus}
|
|
codeTheme="github"
|
|
codeStyleReverse={false}
|
|
ref={renderCallback}
|
|
value={val}
|
|
theme={getTheme()}
|
|
language={UserContext.viewLang === 'zh' ? 'zh-CN' : UserContext.viewLang?.replace('_', '-')}
|
|
noMermaid
|
|
noPrettier
|
|
noKatex
|
|
noHighlight
|
|
autoDetectCode
|
|
toolbarsExclude={[
|
|
// 'bold',
|
|
// 'underline',
|
|
// 'italic',
|
|
// '-',
|
|
// 'strikeThrough',
|
|
// 'title',
|
|
'sub',
|
|
'sup',
|
|
// 'quote',
|
|
// 'unorderedList',
|
|
// 'orderedList',
|
|
// 'task',
|
|
// '-',
|
|
// 'codeRow',
|
|
// 'code',
|
|
// 'link',
|
|
// 'image',
|
|
// 'table',
|
|
'mermaid',
|
|
// 'katex',
|
|
// '-',
|
|
// 'revoke',
|
|
// 'next',
|
|
'save',
|
|
// '=',
|
|
'pageFullscreen',
|
|
'fullscreen',
|
|
// 'preview',
|
|
'previewOnly',
|
|
'htmlPreview',
|
|
// 'catalog',
|
|
'github',
|
|
]}
|
|
onChange={(v) => {
|
|
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(uploadCfg?.endpoint || '/file', [files[0]], {
|
|
type: uploadCfg?.type,
|
|
filenameCallback: () => filename,
|
|
}).then(() => {
|
|
callback([`${useFileProtocol ? 'file://' : `/file/${UserContext._id}/`}${filename}`]);
|
|
}).catch(() => {
|
|
callback([]);
|
|
});
|
|
return null;
|
|
}}
|
|
/>;
|
|
}
|
|
|
|
this.valueCache = value;
|
|
|
|
this.reactRoot = ReactDOM.createRoot(ele);
|
|
this.reactRoot.render(<EditorComponent />);
|
|
$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 });
|
|
}
|
|
}
|