Merge branch 'master' into monaco

This commit is contained in:
undefined committed 2024-07-10 11:07:48 +08:00
commit 0951017539
93 files changed
+1414 -736

No files matched your search

@@ -24,9 +24,8 @@ const Component = React.forwardRef<any, any>((props, ref) => {
);
});
export default class CustomSelectAutoComplete<Multi extends boolean> extends AutoComplete {
export default class CustomSelectAutoComplete<Multi extends boolean> extends AutoComplete<CustomSelectOptions> {
static DOMAttachKey = 'ucwCustomSelectAutoCompleteInstance';
options: CustomSelectOptions & AutoCompleteOptions;
constructor($dom, options: CustomSelectOptions & AutoCompleteOptions<Multi>) {
super($dom, {
@@ -27,9 +27,8 @@ const Component = React.forwardRef<any, any>((props, ref) => {
);
});
export default class FileSelectAutoComplete<Multi extends boolean> extends AutoComplete {
export default class FileSelectAutoComplete<Multi extends boolean> extends AutoComplete<FileSelectOptions> {
static DOMAttachKey = 'ucwFileSelectAutoCompleteInstance';
options: FileSelectOptions & AutoCompleteOptions;
constructor($dom, options: FileSelectOptions & AutoCompleteOptions<Multi>) {
super($dom, {
@@ -11,10 +11,7 @@ const CustomSelectAutoComplete = React.forwardRef<any, any>((props, ref) => (
<AutoComplete<DefaultProps>
ref={ref as any}
fetchItems={(keys) => props.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))}
queryItems={(query) => props.data.filter((i) => (i.name
? i.name.toLowerCase().match(query.toLowerCase())
: i.toString().toLowerCase().match(query.toLowerCase())
))}
queryItems={(query) => props.data.filter((i) => (i.name || i).toString().toLowerCase().includes(query.toLowerCase()))}
itemText={(item) => `${item.name || item}`}
itemKey={(item) => `${item._id?.toString() || item.name || item}`}
renderItem={(item) => `${item.name || item}`}
@@ -8,6 +8,7 @@ export interface AutoCompleteOptions<Multi extends boolean = boolean> {
defaultItems?: string;
width?: string;
height?: string;
classes?: string;
listStyle?: any;
allowEmptyQuery?: boolean;
freeSolo?: boolean;
@@ -18,17 +19,17 @@ export interface AutoCompleteOptions<Multi extends boolean = boolean> {
text?: () => string;
}
export default class AutoComplete extends DOMAttachedObject {
export default class AutoComplete<Options extends Record<string, any> = {}> extends DOMAttachedObject {
static DOMAttachKey = 'ucwAutoCompleteInstance';
ref = null;
container = document.createElement('div');
options: AutoCompleteOptions;
options: AutoCompleteOptions & Options;
component = ReactDOM.createRoot(this.container);
changeListener = [
(val) => this.$dom.val(val),
];
constructor($dom, options = {}) {
constructor($dom, options = {} as Options) {
super($dom);
this.options = {
items: async () => [],
@@ -6,9 +6,13 @@
.drop.nav__dropdown
max-height: calc(100% - 60px)
overflow-x: hidden
overflow-y: auto
box-shadow: 0rem 0.125rem 0.4375rem rgba(0 0 0 , 30%);
.drop.nav__dropdown.drop-open
overflow-x: auto
.dropdown
.menu
min-width: rem(150px)
@@ -1,12 +1,3 @@
.vditor
margin-bottom: rem(10px)
.vditor-preview__action
display: none
.vditor ol
list-style: decimal
.decoration-hide
color: grey !important
background-color: grey !important
@@ -1,36 +1,11 @@
import $ from 'jquery';
import type { editor } from 'monaco-editor';
import { nanoid } from 'nanoid';
import React from 'react';
import ReactDOM from 'react-dom/client';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import Notification from 'vj/components/notification';
import { i18n, request } from 'vj/utils';
export const config = {
toolbar: [
'emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|',
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
'quote', 'line', 'code', 'inline-code', 'table', '|',
'upload', 'edit-mode', 'fullscreen', 'export',
],
mode: UserContext.preferredEditorType || 'ir',
toolbarConfig: {
pin: true,
},
cdn: `${UiContext.cdn_prefix}vditor`,
counter: {
enable: true,
max: 65536,
},
preview: {
math: {
inlineDigit: true,
},
theme: {
path: `${UiContext.cdn_prefix}vditor/dist/css/content-theme`,
current: 'light',
},
},
};
import { i18n } from 'vj/utils';
import uploadFiles from '../upload';
interface MonacoOptions {
language?: string;
@@ -43,23 +18,23 @@ interface MonacoOptions {
hide?: string[];
lineNumbers?: 'on' | 'off' | 'relative' | 'interval';
}
interface VditorOptions {
theme?: 'classic' | 'dark'
}
type Options = MonacoOptions & VditorOptions;
type Options = MonacoOptions;
export default class Editor extends DOMAttachedObject {
static DOMAttachKey = 'vjEditorInstance';
model: editor.IModel;
editor: editor.IStandaloneCodeEditor;
vditor: import('vditor').default;
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.initVditor();
else this.initMarkdownEditor();
}
async initMonaco() {
@@ -169,87 +144,102 @@ export default class Editor extends DOMAttachedObject {
window.editor = this.editor;
}
async initVditor() {
async initMarkdownEditor() {
const pagename = document.documentElement.getAttribute('data-page');
const isProblemPage = ['problem_create', 'problem_edit'].includes(pagename);
const isProblemEdit = pagename === 'problem_edit';
const { default: Vditor } = await import('vditor');
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;
await new Promise((resolve) => {
this.vditor = new Vditor(ele, {
...config,
upload: {
multiple: false,
handler: (files) => {
let wrapper = ['', ''];
let ext: string;
const matches = files[0].type.match(/^image\/(png|jpg|jpeg|gif)$/i);
if (matches) {
wrapper = ['![image](', ')'];
[, ext] = matches;
} else if (files[0].type === 'application/x-zip-compressed') {
wrapper = ['[file](', ')'];
ext = 'zip';
}
if (!ext) return i18n('No Supported file type.');
const filename = `${nanoid()}.${ext}`;
const data = new FormData();
data.append('filename', filename);
data.append('file', files[0]);
data.append('operation', 'upload_file');
if (isProblemEdit) data.append('type', 'additional_file');
let progress = 0;
request.postFile(isProblemEdit ? './files' : '/file', data, {
xhr: () => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', () => this.vditor.vditor.tip.show(i18n('Uploading...'), 0));
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) return;
const percentComplete = Math.round((e.loaded / e.total) * 100);
if (percentComplete === progress) return;
progress = percentComplete;
this.vditor.vditor.tip.show(`${i18n('Uploading...')} ${percentComplete}%`, 0);
}, false);
return xhr;
},
})
.then(() => {
this.vditor.insertValue(`${wrapper.join(`${isProblemPage ? 'file://' : `/file/${UserContext._id}/`}${filename}`)} `);
this.vditor.vditor.tip.hide();
})
.catch((e: { message: string; }) => {
console.error(e);
return `${i18n('Upload Failed')}: ${e.message}`;
});
return null;
},
},
...this.options,
after: () => {
const pos = $(ele).find('button[data-mode="sv"]').get();
const button = $('<button data-mode="monaco">Monaco Editor</button>');
button.on('click', (e) => {
Notification.info('You can select this in PreferenceSettings');
e.preventDefault();
});
button.insertAfter(pos);
resolve(null);
},
input(v) {
const { MdEditor } = await import('./mdeditor');
const renderCallback = (ref) => {
this.markdownEditor = ref;
};
function EditorComponent() {
const [val, setVal] = React.useState(value);
that.setMarkdownEditorValue = setVal;
return <MdEditor
className='textbox'
autoFocus={hasFocus}
codeTheme='github'
codeStyleReverse={false}
ref={renderCallback}
modelValue={val}
noMermaid
noPrettier
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);
if (onChange) onChange(v);
},
value,
cache: { id: Math.random().toString() },
});
});
$(ele).addClass('textbox');
onChange?.(v);
}}
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.reactRoot = ReactDOM.createRoot(ele);
this.reactRoot.render(<EditorComponent />);
$dom.hide();
origin.parentElement.appendChild(ele);
this.isValid = true;
@@ -258,7 +248,7 @@ export default class Editor extends DOMAttachedObject {
destroy() {
this.detach();
if (this.vditor?.destroy) this.vditor.destroy();
if (this.reactRoot) this.reactRoot.unmount();
else if (this.editor?.dispose) this.editor.dispose();
}
@@ -268,13 +258,17 @@ export default class Editor extends DOMAttachedObject {
value(val?: string) {
this.ensureValid();
if (typeof val === 'string') return (this.editor || this.vditor).setValue(val);
return (this.editor || this.vditor).getValue({ lineEnding: '\n', preserveBOM: false });
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();
this.vditor?.focus();
if (!this.editor || !this.model) return;
this.editor.focus();
const range = this.model.getFullModelRange();
@@ -0,0 +1,39 @@
import 'md-editor-rt/lib/style.css';
import Anchor from 'markdown-it-anchor';
import Footnote from 'markdown-it-footnote';
import Mark from 'markdown-it-mark';
import MergeCells from 'markdown-it-merge-cells';
import TOC from 'markdown-it-table-of-contents';
import { config, MdEditor } from 'md-editor-rt';
import Imsize from '../../backendlib/markdown-it-imsize';
import { Media } from '../../backendlib/markdown-it-media';
import { xssProtector } from '../../backendlib/markdown-it-xss';
const pagename = document.documentElement.getAttribute('data-page');
const isProblemPage = ['problem_create', 'problem_edit'].includes(pagename);
config({
markdownItConfig(mdit) {
mdit.options.html = true;
mdit.options.linkify = true;
mdit.linkify.tlds('.py', false);
mdit.linkify.tlds('.zip', false);
mdit.linkify.tlds('.mov', false);
mdit.use(Media);
mdit.use(Footnote);
mdit.use(Mark);
mdit.use(Imsize);
mdit.use(Anchor);
mdit.use(TOC);
mdit.use(MergeCells);
mdit.use(xssProtector);
if (isProblemPage) {
mdit.core.ruler.before('normalize', 'xss', (state) => {
state.src = state.src.replace(/file:\/\//g, pagename === 'problem_create' ? `/file/${UserContext._id}/` : './file/');
});
}
},
});
export { MdEditor };
@@ -1,5 +1,4 @@
import { nanoid } from 'nanoid';
import ReconnectingWebsocket from 'reconnecting-websocket';
import { InfoDialog } from 'vj/components/dialog';
import VjNotification from 'vj/components/notification/index';
import {
@@ -7,6 +6,7 @@ import {
} from 'vj/constant/message';
import { AutoloadPage } from 'vj/misc/Page';
import { i18n, tpl } from 'vj/utils';
import Sock from '../socket';
let previous: VjNotification;
const onmessage = (msg) => {
@@ -61,7 +61,7 @@ const onmessage = (msg) => {
const initWorkerMode = (endpoint) => {
console.log('Messages: using SharedWorker');
const worker = new SharedWorker('/messages-shared-worker.js', { name: 'HydroMessagesWorker' });
const worker = new SharedWorker(new URL('./worker.ts', import.meta.url), { name: 'HydroMessagesWorker' });
worker.port.start();
window.addEventListener('beforeunload', () => {
worker.port.postMessage({ type: 'unload' });
@@ -128,12 +128,8 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => {
isMaster = true;
localStorage.setItem('page.master', selfId);
const masterChannel = new BroadcastChannel('hydro-messages');
const sock = new ReconnectingWebsocket(endpoint);
sock.onopen = () => console.log('Connected');
sock.onerror = console.error;
sock.onclose = (...args) => console.log('Closed', ...args);
const sock = new Sock(endpoint);
sock.onmessage = async (message) => {
if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message);
const payload = JSON.parse(message.data);
masterChannel.postMessage({ type: 'message', payload });
};
@@ -31,12 +31,17 @@ function broadcastMsg(message: any) {
function initConn(path: string, port: MessagePort, cookie: any) {
if (cookie !== lcookie) conn?.close();
else if (conn && conn.readyState === conn.OPEN) return;
const url = new URL(path, location.origin);
if (cookie) url.searchParams.set('sid', cookie.split('sid=')[1].split(';')[0]);
lcookie = cookie;
console.log('Init connection for', path);
conn?.close();
conn = new ReconnectingWebsocket(path);
conn = new ReconnectingWebsocket(url.toString());
ports.push(port);
conn.onopen = () => conn.send(cookie);
conn.onopen = () => {
console.log('Connected to', path);
broadcastMsg({ type: 'open' });
};
conn.onerror = () => broadcastMsg({ type: 'error' });
conn.onclose = (ev) => broadcastMsg({ type: 'close', error: ev.reason });
conn.onmessage = (message) => {
@@ -46,14 +51,9 @@ function initConn(path: string, port: MessagePort, cookie: any) {
return;
}
const payload = JSON.parse(message.data);
if (payload.event === 'auth') {
if (['PermissionError', 'PrivilegeError'].includes(payload.error)) {
broadcastMsg({ type: 'close', error: payload.error });
conn.close();
} else {
console.log('Connected to', path);
broadcastMsg({ type: 'open' });
}
if (['PermissionError', 'PrivilegeError'].includes(payload.error)) {
broadcastMsg({ type: 'close', error: payload.error });
conn.close();
} else {
broadcastMsg({ type: 'message', payload });
let acked = false;
@@ -87,8 +87,8 @@ export function configYamlFormat(config: ProblemConfigFile) {
export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent<Props> {
disposable = [];
containerElement: HTMLElement;
private __preventUpdate = false;
private __preventFormat = false;
__preventUpdate = false;
__preventFormat = false;
editor: editor.IStandaloneCodeEditor;
model: editor.ITextModel;
@@ -1,6 +1,7 @@
let cacheKey = `${UserContext._id}/${UiContext.pdoc.domainId}/${UiContext.pdoc.docId}`;
if (UiContext.tdoc?._id && UiContext.tdoc.rule !== 'homework') cacheKey += `@${UiContext.tdoc._id}`;
// TODO switch to indexeddb
export default function reducer(state = {
lang: localStorage.getItem(`${cacheKey}#lang`) || UiContext.codeLang,
code: localStorage.getItem(cacheKey) || UiContext.codeTemplate,
@@ -54,6 +54,10 @@ export default class Sock {
onclose: (code: number, reason: string) => void;
onopen: (sock: ReconnectingWebSocket) => void;
on(event: 'message' | 'close' | 'open', callback: (...args: any[]) => void) {
this[`on${event}`] = callback;
}
send(data) {
this.sock.send(data);
}
@@ -1,5 +1,4 @@
import $ from 'jquery';
import _ from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
const TAB_TRANSITION_DURATION = 300;
@@ -144,5 +143,3 @@ export default class Tab extends DOMAttachedObject {
return true;
}
}
_.assign(Tab, DOMAttachedObject);
@@ -73,6 +73,26 @@
background: $secondary-color
content: ''
.section__tab-container:not(.immersive)
.section__tab-header
padding-top: 0 !important
.section__tab-header-item
border-top: 3px solid transparent;
transition: border-color 0.2s, background 0.2s, color 0.2s;
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
margin-right: 0
&:hover
border-color: #5f9fd6;
background: #f8f8f8;
&.tab--active
border-color: #ed5f82;
&.selected
border-color: #ed5f82;
.section__tab-content
position: relative
+2 -1
View File
@@ -13,6 +13,7 @@ interface UploadOptions {
pjax?: boolean;
sidebar?: boolean;
singleFileUploadCallback?: (file: File) => any;
filenameCallback?: (file: File) => string;
}
export default async function uploadFiles(endpoint = '', files: File[] | FileList = [], options: UploadOptions = {}) {
const dialog = new Dialog({
@@ -38,7 +39,7 @@ export default async function uploadFiles(endpoint = '', files: File[] | FileLis
if (Number.isNaN(+i)) continue;
const file = files[i];
const data = new FormData();
data.append('filename', file.name);
data.append('filename', options.filenameCallback?.(file) || file.name);
data.append('file', file);
if (options.type) data.append('type', options.type);
data.append('operation', 'upload_file');