ui: milkdown

This commit is contained in:
undefined committed 2022-05-03 23:59:47 +08:00
1 parent 4f4e6f9377
commit 8e95482246
11 files changed
+542 -155

No files matched your search

@@ -1,12 +1,7 @@
.vditor
margin-bottom: rem(10px)
.vditor-preview__action
display: none
.vditor ol
list-style: decimal
.decoration-hide
color: grey !important
background-color: grey !important
.code-fence_selector
height: 2.2em !important
width: 10em !important
+135 -132
View File
@@ -1,32 +1,11 @@
import _ from 'lodash';
import Notification from 'vj/components/notification';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import type { Node } from 'prosemirror-model';
import { nanoid } from 'nanoid';
import i18n from 'vj/utils/i18n';
import request from 'vj/utils/request';
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,
},
},
};
import tpl from 'vj/utils/tpl';
interface MonacoOptions {
language?: string;
@@ -38,26 +17,35 @@ interface MonacoOptions {
value?: string;
hide?: string[];
}
interface VditorOptions {
theme?: 'classic' | 'dark'
}
type Options = MonacoOptions & VditorOptions;
type Options = MonacoOptions;
export default class Editor extends DOMAttachedObject {
export default class VjEditor extends DOMAttachedObject {
static DOMAttachKey = 'vjEditorInstance';
isValid: boolean;
private lock: boolean;
model: import('../monaco').default.editor.IModel;
editor: import('../monaco').default.editor.IStandaloneCodeEditor;
vditor: import('vditor').default;
isValid: boolean;
milkdown: import('@milkdown/core').Editor & {
setValue?: (val: string) => void;
getValue?: () => string;
dispose?: () => void;
};
height = 0;
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();
$dom.hide();
const root = $('<div class="row editor-root" style="width: 100%;"></div>');
const left = $('<div class="medium-6 columns textbox"></div>');
const right = $('<div class="medium-6 columns textbox typo"></div>');
root.append(left, right);
$($dom).parent().append(root);
this.initMonaco(left.get(0));
if ((options?.language || 'markdown') === 'markdown') this.initMilkdown(right.get(0));
}
async initMonaco() {
async initMonaco(ele) {
const { load } = await import('vj/components/monaco/loader');
const {
onChange, language = 'markdown',
@@ -67,16 +55,9 @@ export default class Editor extends DOMAttachedObject {
hide = [],
} = 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();
// eslint-disable-next-line no-nested-ternary
const hasFocus = this.$dom.is(':focus') || this.$dom.hasClass('autofocus');
if (!autoResize && this.$dom.height()) $(ele).height(this.$dom.height());
const value = this.options.value || this.$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))
@@ -100,7 +81,7 @@ export default class Editor extends DOMAttachedObject {
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;
let height = Math.max(this.editor.getTopForLineNumber(lineCount + 1) + lineHeight, this.height);
if (prevHeight !== height) {
if (window.innerHeight * 1.5 < height) {
height = window.innerHeight;
@@ -147,9 +128,13 @@ export default class Editor extends DOMAttachedObject {
}
this.editor.onDidChangeModelContent(() => {
const val = this.editor.getValue({ lineEnding: '\n', preserveBOM: false });
$dom.val(val);
$dom.text(val);
if (onChange) onChange(val);
this.$dom.val(val);
this.$dom.text(val);
if (this.lock) return;
this.lock = true;
this.milkdown?.setValue(val);
onChange?.(val);
this.lock = false;
});
this.isValid = true;
if (hasFocus) this.focus();
@@ -160,97 +145,111 @@ export default class Editor extends DOMAttachedObject {
window.editor = this.editor;
}
async initVditor() {
async initMilkdown(ele) {
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 { $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) {
$dom.val(v);
$dom.text(v);
if (onChange) onChange(v);
},
value,
cache: { id: Math.random().toString() },
const [
{
Editor, rootCtx, defaultValueCtx, editorViewCtx, parserCtx, serializerCtx,
},
{ nord }, { Slice }, { gfm }, { listener, listenerCtx },
{ math }, { history }, { clipboard }, { tooltip }, { slash },
{ emoji }, { upload, uploadPlugin }, { diagram }, { prism }, { cursor },
{ menu },
] = await Promise.all([
import('@milkdown/core'), import('./theme'), import('@milkdown/prose'),
import('@milkdown/preset-gfm'), import('@milkdown/plugin-listener'),
import('@milkdown/plugin-math'),
import('@milkdown/plugin-history'),
import('@milkdown/plugin-clipboard'),
import('@milkdown/plugin-tooltip'),
import('@milkdown/plugin-slash'),
import('@milkdown/plugin-emoji'),
import('@milkdown/plugin-upload'),
import('@milkdown/plugin-diagram'),
import('@milkdown/plugin-prism'),
import('@milkdown/plugin-cursor'),
import('@milkdown/plugin-menu'),
]);
const uploader: import('@milkdown/plugin-upload').Uploader = async (files, schema) => {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
if (!file.type.includes('image')) continue;
images.push(file);
}
const nodes: Node[] = await Promise.all(
images.map(async (image) => {
console.log(image);
// TODO
return null;
const src = await YourUploadAPI(image);
const alt = image.name;
return schema.nodes.image.createAndFill({
src,
alt,
}) as Node;
}),
);
return nodes;
};
this.milkdown = Editor.make()
.use(nord).use(listener).use(gfm)
.use(math).use(history).use(clipboard).use(tooltip).use(slash)
.use(emoji).use(diagram).use(prism).use(cursor).use(menu)
.use(upload.configure(uploadPlugin, { uploader }))
.config((ctx) => {
ctx.set(defaultValueCtx, this.$dom.val());
ctx.set(rootCtx, ele);
ctx.get(listenerCtx).markdownUpdated((c, markdown) => {
this.$dom.val(markdown);
this.$dom.text(markdown);
if (this.lock) return;
this.lock = true;
if (this.model) {
const range = this.model.getFullModelRange();
this.model.pushEditOperations([], [{ range, text: markdown }], undefined);
} else this.editor?.setValue?.(markdown);
this.options?.onChange?.(markdown);
this.lock = false;
});
ctx.get(listenerCtx).updated((c) => {
this.height = $(ele).height();
});
});
this.milkdown.setValue = (val) => {
this.milkdown.action((ctx) => {
const view = ctx.get(editorViewCtx);
const doc = ctx.get(parserCtx)(val);
if (!doc) return;
view.dispatch(view.state.tr.replace(0, view.state.doc.content.size, new Slice(doc.content, 0, 0)));
});
};
this.milkdown.getValue = () => this.milkdown.action((ctx) => {
const editorView = ctx.get(editorViewCtx);
const serializer = ctx.get(serializerCtx);
return serializer(editorView.state.doc);
});
$(ele).addClass('textbox');
$dom.hide();
origin.parentElement.appendChild(ele);
this.milkdown.dispose = () => {
const view = this.milkdown.action((ctx) => ctx.get(editorViewCtx));
const root = this.milkdown.action((ctx) => ctx.get(rootCtx)) as HTMLElement;
root?.firstChild?.remove();
view?.destroy();
};
await this.milkdown.create();
$('.editor-root').prepend($('<div class="editor-menu columns"></div>'));
$('.editor-menu').prepend($('.milkdown-menu'));
this.isValid = true;
if (hasFocus) this.focus();
}
destory() {
this.detach();
if (this.vditor?.destroy) this.vditor.destroy();
else if (this.editor?.dispose) this.editor.dispose();
this.editor?.dispose();
this.milkdown?.dispose();
}
ensureValid() {
@@ -259,8 +258,12 @@ 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') {
this.editor?.setValue(val);
this.milkdown?.setValue(val);
return val;
}
return (this.editor || this.milkdown)?.getValue({ lineEnding: '\n', preserveBOM: false });
}
focus() {
@@ -271,4 +274,4 @@ export default class Editor extends DOMAttachedObject {
}
}
_.assign(Editor, DOMAttachedObject);
_.assign(VjEditor, DOMAttachedObject);
@@ -0,0 +1,380 @@
/* Copyright 2021, Milkdown by Mirone. */
/* eslint-disable consistent-return */
import {
Emotion, ThemeBorder, ThemeFont, ThemeManager, ThemeScrollbar, ThemeShadow, ThemeSize,
hex2rgb, ThemeColor, themeFactory, ThemeGlobal, ThemeIcon,
} from '@milkdown/core';
import { getPalette } from '@milkdown/design-system';
import { injectProsemirrorView, useAllPresetRenderer } from '@milkdown/theme-pack-helper';
import { code, typography } from '@milkdown/theme-nord/src/font';
import { getIcon } from '@milkdown/theme-nord/src/icon';
import { darkColor, lightColor } from '@milkdown/theme-nord/src/nord';
export const font = {
typography,
code,
};
export const size = {
radius: '4px',
lineWidth: '1px',
};
export const getStyle = (manager: ThemeManager, emotion: Emotion) => {
const { injectGlobal, css } = emotion;
const palette = getPalette(manager);
const radius = manager.get(ThemeSize, 'radius');
const neutral = palette('neutral', 0.87);
const surface = palette('surface');
const line = palette('line');
const highlight = palette('secondary', 0.38);
const selection = css`
.ProseMirror-selectednode {
outline: ${manager.get(ThemeSize, 'lineWidth')} solid ${line};
}
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode::after {
${manager.get(ThemeBorder, undefined)};
}
& ::selection {
background: ${highlight};
}
`;
const editorLayout = css`
padding: 1.25em;
outline: none;
`;
const blockquote = css`
blockquote {
padding-left: 1.875em;
line-height: 1.75em;
border-left: 4px solid ${palette('primary')};
margin-left: 0;
margin-right: 0;
* {
font-size: 1em;
line-height: 1.5em;
}
}
`;
const hr = css`
hr {
height: ${manager.get(ThemeSize, 'lineWidth')};
background-color: ${line};
border-width: 0;
}
`;
const list = css`
.list-item,
.list-item > * {
margin: 0.5em 0;
}
li {
&::marker {
color: ${palette('primary')};
}
}
.task-list-item {
display: flex;
flex-direction: row;
align-items: flex-start;
&_checkbox {
margin: 0.5em 0.5em 0.5em 0;
height: 1em;
}
}
`;
// eslint-disable-next-line no-shadow
const code = css`
.code-fence {
pre {
font-family: ${manager.get(ThemeFont, 'code')};
margin: 0 1.2em !important;
white-space: pre;
overflow: auto;
${manager.get(ThemeScrollbar, ['x'])}
background-color: ${palette('background')};
color: ${palette('neutral')};
font-size: 0.875em;
border-radius: ${radius};
code {
line-height: 1.5;
font-family: ${manager.get(ThemeFont, 'code')};
}
}
}
`;
const img = css`
.image {
display: inline-block;
margin: 0 auto;
object-fit: contain;
width: 100%;
position: relative;
height: auto;
text-align: center;
}
`;
const inline = css`
.code-inline {
background-color: ${palette('neutral')};
color: ${palette('background')};
border-radius: ${radius};
font-weight: 500;
font-family: ${code};
padding: 0 0.2em;
font-size: 1.2em;
}
.strong {
font-weight: 600;
}
.link,
a {
color: ${palette('secondary')};
cursor: pointer;
transition: all 0.4s ease-in-out;
font-weight: 500;
&:hover {
background-color: ${palette('line')};
box-shadow: 0 0.2em ${palette('line')}, 0 -0.2em ${palette('line')};
}
}
.strike-through {
text-decoration-color: ${palette('secondary')};
}
`;
const footnote = css`
.footnote-definition {
${manager.get(ThemeBorder, undefined)};
border-radius: ${manager.get(ThemeSize, 'radius')};
background-color: ${palette('background')};
padding: 1em;
display: flex;
flex-direction: row;
& > .footnote-definition_content {
flex: 1;
width: calc(100% - 1em);
& > dd {
margin-inline-start: 1em;
}
& > dt {
color: ${palette('secondary')};
font-weight: 500;
}
}
& > .footnote-definition_anchor {
width: 1em;
}
}
`;
const table = css`
.tableWrapper {
overflow-x: auto;
margin: 0;
${manager.get(ThemeScrollbar, ['x'])}
width: 100%;
* {
margin: 0;
box-sizing: border-box;
font-size: 1em;
}
}
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
overflow: auto;
border-radius: ${manager.get(ThemeSize, 'radius')};
p {
line-height: unset;
}
}
tr {
${manager.get(ThemeBorder, 'bottom')};
}
td,
th {
padding: 0 1em;
vertical-align: top;
box-sizing: border-box;
position: relative;
min-width: 100px;
${manager.get(ThemeBorder, undefined)};
text-align: left;
line-height: 3;
height: 3em;
}
th {
background: ${palette('background', 0.5)};
font-weight: 400;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
z-index: 20;
pointer-events: none;
background: ${palette('secondary')};
width: ${manager.get(ThemeSize, 'lineWidth')};
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.selectedCell {
&::after {
z-index: 2;
position: absolute;
content: '';
left: 0;
right: 0;
top: 0;
bottom: 0;
background: ${palette('secondary', 0.38)};
pointer-events: none;
}
& ::selection {
background: transparent;
}
}
`;
injectProsemirrorView(emotion);
// eslint-disable-next-line no-unused-expressions
injectGlobal`
.milkdown {
.material-icons-outlined {
font-size: 1.5em;
}
position: relative;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
color: ${neutral};
background: ${surface};
font-family: ${manager.get(ThemeFont, 'typography')};
${manager.get(ThemeShadow, undefined)}
${manager.get(ThemeScrollbar, undefined)}
${selection};
.editor {
${editorLayout};
${blockquote};
${hr};
${list};
${code};
${img};
${table};
${footnote};
${inline};
}
}
`;
};
export const createTheme = (isDarkMode: boolean) => (emotion: Emotion, manager: ThemeManager) => {
const { css } = emotion;
const colorSet = isDarkMode ? darkColor : lightColor;
manager.set(ThemeColor, (options) => {
if (!options) return;
const [key, opacity] = options;
const hex = colorSet[key];
const rgb = hex2rgb(hex);
if (!rgb) return;
return `rgba(${rgb?.join(', ')}, ${opacity || 1})`;
});
manager.set(ThemeSize, (key) => {
if (!key) return;
return size[key];
});
manager.set(ThemeFont, (key) => {
if (!key) return;
return font[key].join(', ');
});
manager.set(ThemeScrollbar, ([direction = 'y', type = 'normal'] = ['y', 'normal'] as never) => {
const main = manager.get(ThemeColor, ['secondary', 0.38]);
const bg = manager.get(ThemeColor, ['secondary', 0.12]);
const hover = manager.get(ThemeColor, ['secondary']);
return css`
scrollbar-width: thin;
scrollbar-color: ${main} ${bg};
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
${direction === 'y' ? 'width' : 'height'}: ${type === 'thin' ? 2 : 12}px;
background-color: transparent;
}
&::-webkit-scrollbar-track {
border-radius: 999px;
background: transparent;
border: 4px solid transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 999px;
background-color: ${main};
border: ${type === 'thin' ? 0 : 4}px solid transparent;
background-clip: content-box;
}
&::-webkit-scrollbar-thumb:hover {
background-color: ${hover};
}
`;
});
manager.set(ThemeShadow, () => {
const lineWidth = manager.get(ThemeSize, 'lineWidth');
const getShadow = (opacity: number) => manager.get(ThemeColor, ['shadow', opacity]);
return css`
box-shadow: 0 ${lineWidth} ${lineWidth} ${getShadow(0.14)}, 0 2px ${lineWidth} ${getShadow(0.12)},
0 ${lineWidth} 3px ${getShadow(0.2)};
`;
});
manager.set(ThemeBorder, (direction) => {
const lineWidth = manager.get(ThemeSize, 'lineWidth');
const line = manager.get(ThemeColor, ['line']);
if (!direction) {
return css`border: ${lineWidth} solid ${line};`;
}
return css`${`border-${direction}`}: ${lineWidth} solid ${line};`;
});
manager.set(ThemeIcon, (icon) => {
if (!icon) return;
return getIcon(icon);
});
manager.set(ThemeGlobal, () => {
getStyle(manager, emotion);
});
useAllPresetRenderer(manager, emotion);
};
export const getNord = (isDarkMode = false) => themeFactory((emotion, manager) => createTheme(isDarkMode)(emotion, manager));
export const nordDark = getNord(true);
export const nordLight = getNord(false);
const darkMode = Boolean(window.matchMedia?.('(prefers-color-scheme: dark)').matches);
export const nord = getNord(darkMode);
export { color, darkColor, lightColor } from '@milkdown/theme-nord/src/nord';
@@ -1,7 +1,7 @@
@import './var.inc.styl'
.button
&:not(.codicon)
&:not(.codicon):not(.milkdown-nutoqn)
background-color: lighten($default-color, 50%)
border: 1px solid $default-color
transition: background-color .2s, box-shadow .5s
@@ -1,7 +1,7 @@
@import './var.inc.styl'
.button
&:not(.codicon)
&:not(.codicon):not(.milkdown-nutoqn)
font-size: rem($font-size-secondary)
line-height: rem($form-control-height - 2)
height: rem($form-control-height)