core&ui: support pasting image in editor

This commit is contained in:
undefined committed 2022-03-01 21:40:09 +08:00
1 parent 6a289f788d
commit 8223b45d23
13 files changed
+116 -24

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hydrooj", "name": "hydrooj",
"version": "3.5.5", "version": "3.5.6",
"bin": "bin/hydrooj.js", "bin": "bin/hydrooj.js",
"main": "src/loader", "main": "src/loader",
"module": "src/loader", "module": "src/loader",
+13 -1
View File
@@ -790,8 +790,20 @@ export class ProblemCreateHandler extends Handler {
domainId: string, title: string, content: string, pid: string, domainId: string, title: string, content: string, pid: string,
hidden = false, difficulty = 0, tag: string[] = [], assign: string[] = [], hidden = false, difficulty = 0, tag: string[] = [], assign: string[] = [],
) { ) {
if (pid && await problem.get(domainId, pid)) throw new BadRequestError('invalid pid'); if (pid && await problem.get(domainId, pid)) throw new BadRequestError('ProblemID already exists');
const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden, assign); const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden, assign);
const files = new Set(Array.from(content.matchAll(/file:\/\/([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)/g)).map((i) => i[1]));
const tasks = [];
for (const file of files) {
if (this.user._files.find((i) => i.name === file)) {
tasks.push(
storage.rename(`user/${this.user._id}/${file}`, `problem/${domainId}/${docId}/additional_file/${file}`)
.then(() => problem.addAdditionalFile(domainId, docId, file, '', true)),
user.setById(this.user._id, { _files: this.user._files.filter((i) => i.name !== file) }),
);
}
}
await Promise.all(tasks);
if (difficulty) await problem.edit(domainId, docId, { difficulty }); if (difficulty) await problem.edit(domainId, docId, { difficulty });
this.response.body = { pid: pid || docId }; this.response.body = { pid: pid || docId };
this.response.redirect = this.url('problem_files', { pid: pid || docId }); this.response.redirect = this.url('problem_files', { pid: pid || docId });
+2 -2
View File
@@ -264,10 +264,10 @@ export class ProblemModel {
await bus.emit('problem/delTestdata', domainId, pid, names); await bus.emit('problem/delTestdata', domainId, pid, names);
} }
static async addAdditionalFile(domainId: string, pid: number, name: string, f: Readable | Buffer | string) { static async addAdditionalFile(domainId: string, pid: number, name: string, f: Readable | Buffer | string, skipUpload = false) {
const [[, fileinfo]] = await Promise.all([ const [[, fileinfo]] = await Promise.all([
document.getSub(domainId, document.TYPE_PROBLEM, pid, 'additional_file', name), document.getSub(domainId, document.TYPE_PROBLEM, pid, 'additional_file', name),
storage.put(`problem/${domainId}/${pid}/additional_file/${name}`, f), skipUpload || storage.put(`problem/${domainId}/${pid}/additional_file/${name}`, f),
]); ]);
const meta = await storage.getMeta(`problem/${domainId}/${pid}/additional_file/${name}`); const meta = await storage.getMeta(`problem/${domainId}/${pid}/additional_file/${name}`);
const payload = { name, ...pick(meta, ['size', 'lastModified', 'etag']) }; const payload = { name, ...pick(meta, ['size', 'lastModified', 'etag']) };
+5
View File
@@ -108,8 +108,13 @@ class StorageService {
accessKey: this.opts.accessKey, accessKey: this.opts.accessKey,
secretKey: this.opts.secretKey, secretKey: this.opts.secretKey,
}); });
try {
const exists = await this.client.bucketExists(this.opts.bucket); const exists = await this.client.bucketExists(this.opts.bucket);
if (!exists) await this.client.makeBucket(this.opts.bucket, this.opts.region); if (!exists) await this.client.makeBucket(this.opts.bucket, this.opts.region);
} catch (e) {
// Some platform doesn't support bucketExists & makeBucket API.
// Ignore this error.
}
this.replaceWithAlternativeUrlFor = { this.replaceWithAlternativeUrlFor = {
user: parseAlternativeEndpointUrl(this.opts.endPointForUser), user: parseAlternativeEndpointUrl(this.opts.endPointForUser),
judge: parseAlternativeEndpointUrl(this.opts.endPointForJudge), judge: parseAlternativeEndpointUrl(this.opts.endPointForJudge),
+1
View File
@@ -686,6 +686,7 @@ const scripts: UpgradeScript[] = [
async function _59_60() { async function _59_60() {
const langs = await system.get('hydrooj.langs'); const langs = await system.get('hydrooj.langs');
await system.set('hydrooj.langs', langs.replace(/\$\{dir\}/g, '/w').replace(/\$\{name\}/g, 'foo')); await system.set('hydrooj.langs', langs.replace(/\$\{dir\}/g, '/w').replace(/\$\{name\}/g, 'foo'));
return true;
}, },
]; ];
+1 -3
View File
@@ -140,9 +140,7 @@ env.addGlobal('set', (obj, key, val) => {
else Object.assign(obj, key); else Object.assign(obj, key);
return ''; return '';
}); });
env.addGlobal('findSubModule', (prefix) => { env.addGlobal('findSubModule', (prefix) => Object.keys(global.Hydro.ui.template).filter((n) => n.startsWith(prefix)));
filter(Object.keys(global.Hydro.ui.template), (n) => n.startsWith(prefix));
});
async function render(name, state) { async function render(name, state) {
// eslint-disable-next-line no-return-await // eslint-disable-next-line no-return-await
@@ -2,7 +2,9 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { EditorAction, registerEditorAction } from 'monaco-editor/esm/vs/editor/browser/editorExtensions'; import { EditorAction, registerEditorAction } from 'monaco-editor/esm/vs/editor/browser/editorExtensions';
import { IQuickInputService } from 'monaco-editor/esm/vs/platform/quickinput/common/quickInput'; import { IQuickInputService } from 'monaco-editor/esm/vs/platform/quickinput/common/quickInput';
import list from 'monaco-themes/themes/themelist.json'; import list from 'monaco-themes/themes/themelist.json';
import { nanoid } from 'nanoid';
import i18n from 'vj/utils/i18n'; import i18n from 'vj/utils/i18n';
import request from 'vj/utils/request';
import './monaco.styl'; import './monaco.styl';
export default monaco; export default monaco;
@@ -59,6 +61,72 @@ class ChangeThemeAction extends EditorAction {
registerEditorAction(ChangeThemeAction); registerEditorAction(ChangeThemeAction);
const pagename = document.documentElement.getAttribute('data-page');
const isProblemPage = ['problem_create', 'problem_edit'].includes(pagename);
const isProblemEdit = pagename === 'problem_edit';
function handlePasteEvent(editor: monaco.editor.IStandaloneCodeEditor) {
window.addEventListener('paste', (ev: ClipboardEvent) => {
if (!editor.hasTextFocus()) return;
const selection = editor.getSelection();
const { items } = ev.clipboardData;
let wrapper = ['', ''];
let ext;
const matches = items[0].type.match(/^image\/(png|jpg|jpeg|gif)$/i);
if (matches) {
wrapper = ['![image](', ')'];
[, ext] = matches;
} else if (items[0].type === 'application/x-zip-compressed') {
wrapper = ['[file](', ')'];
ext = 'zip';
}
if (!ext) return;
const filename = `${nanoid()}.${ext}`;
const data = new FormData();
data.append('filename', filename);
data.append('file', items[0].getAsFile());
data.append('operation', 'upload_file');
if (isProblemEdit) data.append('type', 'additional_file');
let range: monaco.Range = null;
editor.executeEdits('', [{
range: new monaco.Range(selection.endLineNumber, selection.endColumn, selection.endLineNumber, selection.endColumn),
text: `![image](${i18n('Preparing Upload...')}) `,
}], (inverseOp) => { range = inverseOp[0].range; return null; });
editor.setPosition(editor.getSelection().getEndPosition());
function updateText(text: string) {
const pos = editor.getSelection();
const rangeBefore = range;
editor.executeEdits('', [{ range, text: `${wrapper[0]}${text}${wrapper[1]} ` }], (inverseOp) => {
range = inverseOp[0].range;
if (pos.endLineNumber !== pos.startLineNumber || pos.endLineNumber !== range.endLineNumber) return null;
const delta = rangeBefore.endColumn - range.endColumn;
editor.setPosition(new monaco.Position(pos.endLineNumber, pos.endColumn - delta));
return null;
});
}
let progress = 0;
request.postFile(isProblemEdit ? './file' : '/file', data, {
xhr() {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', () => updateText(i18n('Uploading...')));
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) return;
const percentComplete = Math.round((e.loaded / e.total) * 100);
if (percentComplete === progress) return;
progress = percentComplete;
updateText(`${i18n('Uploading...')} ${percentComplete}%`);
}, false);
return xhr;
},
})
.then(() => updateText(`${isProblemPage ? 'file://' : `/file/${UserContext._id}/`}${filename}`))
.catch((e) => {
console.error(e);
updateText(`${i18n('Upload Failed')}: ${e.message}`);
});
});
}
export function registerAction( export function registerAction(
editor: monaco.editor.IStandaloneCodeEditor, editor: monaco.editor.IStandaloneCodeEditor,
model: monaco.editor.IModel, model: monaco.editor.IModel,
@@ -92,5 +160,6 @@ export function registerAction(
if (model.getLanguageId() === 'markdown') { if (model.getLanguageId() === 'markdown') {
const suggestWidget = (editor.getContribution('editor.contrib.suggestController') as any).widget?.value; const suggestWidget = (editor.getContribution('editor.contrib.suggestController') as any).widget?.value;
if (suggestWidget?._setDetailsVisible) suggestWidget._setDetailsVisible(true); if (suggestWidget?._setDetailsVisible) suggestWidget._setDetailsVisible(true);
handlePasteEvent(editor);
} }
} }
@@ -92,6 +92,7 @@ export default class Tab extends DOMAttachedObject {
.removeClass('active'); .removeClass('active');
this.$content this.$content
.css('x', '0'); .css('x', '0');
$newTab.trigger('vjContentShow');
// 4. Finalize // 4. Finalize
this.currentIndex = idx; this.currentIndex = idx;
+7
View File
@@ -52,6 +52,8 @@ About test data: 关于测试数据
about: 关于 about: 关于
About: 关于 About: 关于
AC Message: 通过消息 AC Message: 通过消息
Accept: 通过
Accepted Problem: 通过的题目
Accepted Ratio: 通过率 Accepted Ratio: 通过率
Accepted: 已通过 Accepted: 已通过
Access Control: 访问控制 Access Control: 访问控制
@@ -574,6 +576,7 @@ Problem Settings: 题目设置
Problem Solution List: 题解列表 Problem Solution List: 题解列表
Problem Solution: 题解列表 Problem Solution: 题解列表
Problem Tags Visibility: 题目标签可见性 Problem Tags Visibility: 题目标签可见性
Problem Tags: 题目标签
problem_create: 创建题目 problem_create: 创建题目
problem_edit: 编辑题目 problem_edit: 编辑题目
problem_import: 导入题目 problem_import: 导入题目
@@ -825,8 +828,12 @@ User ID: 用户 ID
User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。 User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。
user_detail: 用户 user_detail: 用户
user_login: 登录 user_login: 登录
Uploading...: 正在上传...
Upload Failed: 上传失败
Preparing Upload...: 准备上传...
user_logout: 登出 user_logout: 登出
user_lostpass: 忘记密码 user_lostpass: 忘记密码
user_register_with_code: 注册
user_register: 注册 user_register: 注册
User-defined role: 用户定义的角色 User-defined role: 用户定义的角色
User: 用户 User: 用户
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hydrooj/ui-default", "name": "@hydrooj/ui-default",
"version": "4.32.10", "version": "4.32.11",
"author": "undefined <i@undefined.moe>", "author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "hydro.js", "main": "hydro.js",
+11 -12
View File
@@ -99,25 +99,18 @@
{% endif %} {% endif %}
</div> </div>
</li> </li>
</ul>
</div>
</div>
</div>
{% if pdocs.length %} {% if pdocs.length %}
<div class="section"> <li class="section__tab-item">
<div class="section__header"> <h1 class="section__tab-title">{{ _('Accepted Problems') }}</h1>
<h2 class="section__title">{{ _('Accepted Problems') }}</h2> <div class="section__tab-main">
</div>
<div class="typo section__body no-media"> <div class="typo section__body no-media">
{% set _mark = Math.ceil(pdocs.length/4) %} {% set _mark = Math.ceil(pdocs.length/4) %}
{% macro col(pdocs) %} {% macro col(pdocs) %}
<div class="medium-3 columns"> <div class="medium-3 columns"><ul>
<ul>
{% for pdoc in pdocs %} {% for pdoc in pdocs %}
<li>{{ problem.render_problem_title(pdoc, show_tags=false, small=true) }}</li> <li>{{ problem.render_problem_title(pdoc, show_tags=false, small=true) }}</li>
{% endfor %} {% endfor %}
</ul> </ul></div>
</div>
{% endmacro %} {% endmacro %}
<div class="row"> <div class="row">
{{ col(pdocs.slice(0, _mark)) }} {{ col(pdocs.slice(0, _mark)) }}
@@ -127,7 +120,13 @@
</div> </div>
</div> </div>
</div> </div>
</li>
{% endif %} {% endif %}
{% for page in findSubModule('user_detail/') %}{% include page %}{% endfor %}
</ul>
</div>
</div>
</div>
</div> </div>
<div class="medium-3 columns"> <div class="medium-3 columns">
{% if isSelfProfile %} {% if isSelfProfile %}
+2 -2
View File
@@ -21,8 +21,6 @@ const request = {
reject(err); reject(err);
} else if (jqXHR.readyState === 0) { } else if (jqXHR.readyState === 0) {
reject(new Error(i18n('Network error'))); reject(new Error(i18n('Network error')));
} else if (errorThrown instanceof Error) {
reject(errorThrown);
} else if (typeof jqXHR.responseJSON === 'object' && jqXHR.responseJSON.error) { } else if (typeof jqXHR.responseJSON === 'object' && jqXHR.responseJSON.error) {
const { error } = jqXHR.responseJSON; const { error } = jqXHR.responseJSON;
if (error.params) { if (error.params) {
@@ -32,6 +30,8 @@ const request = {
err.params = error.params; err.params = error.params;
reject(err); reject(err);
} else reject(new Error(jqXHR.responseJSON.error.message)); } else reject(new Error(jqXHR.responseJSON.error.message));
} else if (errorThrown instanceof Error) {
reject(errorThrown);
} else { } else {
reject(new Error(textStatus)); reject(new Error(textStatus));
} }
+2 -2
View File
@@ -28,7 +28,7 @@ declare global {
interface SetConstructor { interface SetConstructor {
isSuperset: (set: Set<any>, subset: Set<any>) => boolean; isSuperset: (set: Set<any>, subset: Set<any>) => boolean;
intersection: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>; intersection: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>;
union: <T>(setA: Set<T>, setB: Set<T>) => Set<T>; union: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>;
} }
} }
@@ -124,7 +124,7 @@ Set.isSuperset = function isSuperset(set, subset) {
return true; return true;
}; };
Set.union = function Union<T>(setA: Set<T>, setB: Set<T>) { Set.union = function Union<T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) {
const union = new Set(setA); const union = new Set(setA);
for (const elem of setB) union.add(elem); for (const elem of setB) union.add(elem);
return union; return union;