feat: add plugin-field-markdown-vditor (#4065)

* feat: create vditor field type, use Vditor as Markdown Editor

* feat: clear Markdown Vditor value when set props.value to null

* feat: add plugin-field-markdown-field to preset local plugin

* fix: fix the plugin-field-markdown-vditor name in preset

* fix: fix the plugin-field-markdown-vditor version in preset

* feat: set vditor disable if props.disable is true after init

* feat: use data from localstorage as vditor upload request headers

* fix: plugin-field-markdown-vditor version to 0.21.0-alpha.11

* feat: when fileCollection is not defined, remove upload from vditor toolbar

* feat: add temp function to reset vditor value

* fix: temp function to reset vditor value may include reset tag

* feat: update plugin-field-markdown-vditor i18n

* fix: i18n

* feat: temp disable fullscreen

* fix: remove useless file

* fix: plugin description

* fix: plugin description

* fix: plugin-field-markdown-vditor componentCls

* fix: plugin-field-markdown-vditadd default toobar config

* fix: use long text to save mardkwon

* fix: vditor fullscreen style

* feat: change vditor field datatype

* fix: code review

* fix: code review

* feat: change import method of katex in plugin-field-markdown-vditor

* fix: version

* fix: resize will cause blur

* fix: vditor base font-size

* fix: vditor base font-size

* feat: use style config from token as vditor base size

* fix: plugin-field-markdown-vditor i18n

* fix: toobar config tooltip can not be seen

* fix: vditor toobar default config

* feat: plugin-field-markdown-vditor doc url

* feat: move cursor to end when reset vditor value

* fix: value change will not set vditor

* feat: support getHeaders

* fix: improve component

* fix: enhance vditor init

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Sun668 2024-04-26 08:42:01 +08:00 committed by GitHub
parent 3a0ade464a
commit 4165d8baae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 614 additions and 0 deletions

View File

@ -28,6 +28,22 @@ const handleErrorMessage = (error, notification) => {
};
};
function offsetToTimeZone(offset) {
const hours = Math.floor(Math.abs(offset));
const minutes = Math.abs((offset % 1) * 60);
const formattedHours = (hours < 10 ? '0' : '') + hours;
const formattedMinutes = (minutes < 10 ? '0' : '') + minutes;
const sign = offset >= 0 ? '+' : '-';
return sign + formattedHours + ':' + formattedMinutes;
}
const getCurrentTimezone = () => {
const timezoneOffset = new Date().getTimezoneOffset() / -60;
return offsetToTimeZone(timezoneOffset);
};
const errorCache = new Map();
export class APIClient extends APIClientSDK {
services: Record<string, Result<any, any>> = {};
@ -36,6 +52,16 @@ export class APIClient extends APIClientSDK {
/** 该值会在 AntdAppProvider 中被重新赋值 */
notification: any = notification;
getHeaders() {
const headers = super.getHeaders();
if (this.app) {
headers['X-App'] = this.app.getName();
}
headers['X-Timezone'] = getCurrentTimezone();
headers['X-Hostname'] = window?.location?.hostname;
return headers;
}
service(uid: string) {
return this.services[uid];
}

View File

@ -167,6 +167,10 @@ export class Application {
return this.options;
}
getName() {
return getSubAppName(this.getPublicPath()) || null;
}
getPublicPath() {
let publicPath = this.options.publicPath || '/';
if (!publicPath.endsWith('/')) {

View File

@ -266,6 +266,23 @@ export class APIClient {
auth: Auth;
storage: Storage;
getHeaders() {
const headers = {};
if (this.auth.locale) {
headers['X-Locale'] = this.auth.locale;
}
if (this.auth.role) {
headers['X-Role'] = this.auth.role;
}
if (this.auth.authenticator) {
headers['X-Authenticator'] = this.auth.authenticator;
}
if (this.auth.token) {
headers['Authorization'] = `Bearer ${this.auth.token}`;
}
return headers;
}
constructor(instance?: APIClientOptions) {
if (typeof instance === 'function') {
this.axios = instance;

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-field-markdown-vditor

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,30 @@
{
"name": "@nocobase/plugin-field-markdown-vditor",
"displayName": "Collection field: Markdown(Vditor)",
"displayName.zh-CN": "数据表字段Markdown(Vditor)",
"description": "Used to store Markdown and render it using Vditor editor, supports common Markdown syntax such as list, code, quote, etc., and supports uploading images, recordings, etc.It also allows for instant rendering, where what you see is what you get.",
"description.zh-CN": "用于存储 Markdown并使用 Vditor 编辑器渲染,支持常见 Markdown 语法,如列表,代码,引用等,并支持上传图片,录音等。同时可以做到即时渲染,所见即所得。",
"version": "0.21.0-alpha.15",
"license": "AGPL-3.0",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/field-markdown-vditor",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/field-markdown-vditor",
"peerDependencies": {
"@nocobase/client": "0.x",
"@nocobase/server": "0.x",
"@nocobase/test": "0.x"
},
"devDependencies": {
"@ant-design/icons": "5.x",
"@formily/antd-v5": "1.x",
"@formily/core": "2.x",
"@formily/react": "2.x",
"@formily/shared": "2.x",
"antd": "5.x",
"katex": "^0.16.10",
"vditor": "^3.10.3"
},
"keywords": [
"Collection fields"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,110 @@
import { Field } from '@formily/core';
import { useField } from '@formily/react';
import { Popover } from 'antd';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import Vditor from 'vditor';
import useStyle from './style';
function convertToText(markdownText: string) {
const content = markdownText;
let temp = document.createElement('div');
temp.innerHTML = content;
const text = temp.innerText;
temp = null;
return text?.replace(/[\n\r]/g, '') || '';
}
const getContentWidth = (element) => {
if (element) {
const range = document.createRange();
range.selectNodeContents(element);
const contentWidth = range.getBoundingClientRect().width;
return contentWidth;
}
};
function DisplayInner(props: { value: string; style?: CSSProperties }) {
const containerRef = useRef<HTMLDivElement>();
const { wrapSSR, componentCls, hashId } = useStyle();
useEffect(() => {
if (!props.value) return;
Vditor.preview(containerRef.current, props.value, { mode: 'light' });
}, [props.value]);
return wrapSSR(
<div className={`${hashId} ${componentCls}`}>
<div ref={containerRef} style={{ border: 'none', ...(props?.style ?? {}) }} />
</div>,
);
}
export const Display = (props) => {
const field = useField<Field>();
const value = props.value ?? field.value;
const containerRef = useRef<HTMLDivElement>();
const [popoverVisible, setPopoverVisible] = useState(false);
const [ellipsis, setEllipsis] = useState(false);
const [text, setText] = useState('');
const elRef = useRef<HTMLDivElement>();
useEffect(() => {
if (!props.value || !field.value) return;
if (props.ellipsis) {
Vditor.md2html(props.value, { mode: 'light' })
.then((html) => {
setText(convertToText(html));
})
.catch(() => setText(''));
} else {
Vditor.preview(containerRef.current, props.value ?? field.value, {
mode: 'light',
});
}
}, [props.value, props.ellipsis, field.value]);
const isOverflowTooltip = useCallback(() => {
if (!elRef.current) return false;
const contentWidth = getContentWidth(elRef.current);
const offsetWidth = elRef.current?.offsetWidth;
return contentWidth > offsetWidth;
}, [elRef]);
if (props.ellipsis) {
return (
<Popover
open={popoverVisible}
onOpenChange={(visible) => {
setPopoverVisible(ellipsis && visible);
}}
content={<DisplayInner value={value} style={{ maxWidth: 500, maxHeight: 400, overflowY: 'auto' }} />}
>
<div
ref={elRef}
style={{
overflow: 'hidden',
overflowWrap: 'break-word',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
wordBreak: 'break-all',
}}
onMouseEnter={(e) => {
const el = e.target as any;
const isShowTooltips = isOverflowTooltip();
if (isShowTooltips) {
setEllipsis(el.scrollWidth >= el.clientWidth);
}
}}
>
{text}
</div>
</Popover>
);
}
return <DisplayInner value={value} />;
};

View File

@ -0,0 +1,130 @@
import React, { useRef, useEffect, useLayoutEffect } from 'react';
import Vditor from 'vditor';
import { useAPIClient, withDynamicSchemaProps, useApp } from '@nocobase/client';
import useStyle from './style';
import { defaultToolbar } from '../interfaces/markdown-vditor';
export const Edit = withDynamicSchemaProps((props) => {
const { disabled, onChange, value, fileCollection, toolbar } = props;
const vdRef = useRef<Vditor>();
const vdFullscreen = useRef(false);
const containerRef = useRef<HTMLDivElement>();
const containerParentRef = useRef<HTMLDivElement>();
const app = useApp();
const apiClient = useAPIClient();
const { wrapSSR, hashId, componentCls: containerClassName } = useStyle();
useEffect(() => {
const uploadFileCollection = fileCollection ?? 'attachments';
const toolbarConfig = toolbar ?? defaultToolbar;
const vditor = new Vditor(containerRef.current, {
value,
lang: apiClient.auth.locale.replaceAll('-', '_') as any,
cache: {
enable: false,
},
undoDelay: 0,
preview: {
math: {
engine: 'KaTeX',
},
},
toolbar: toolbarConfig,
fullscreen: {
index: 1200,
},
minHeight: 200,
after: () => {
vdRef.current = vditor;
if (disabled) {
vditor.disabled();
} else {
vditor.enable();
}
},
input(value) {
onChange(value);
},
upload: {
url: app.getApiUrl(`${uploadFileCollection ?? 'attachments'}:create`),
headers: apiClient.getHeaders(),
multiple: false,
fieldName: 'file',
format(files, responseText) {
const response = JSON.parse(responseText);
const formatResponse = {
msg: '',
code: 0,
data: {
errFiles: [],
succMap: {
[response.data.filename]: response.data.url,
},
},
};
return JSON.stringify(formatResponse);
},
},
});
return () => {
vdRef.current?.destroy();
vdRef.current = undefined;
};
}, [fileCollection, toolbar]);
useEffect(() => {
if (value === vdRef?.current?.getValue()) {
return;
}
vdRef.current?.setValue(value);
vdRef.current?.focus();
// 移动光标到末尾
const preArea = containerRef.current.querySelector('div.vditor-content > div.vditor-ir > pre') as HTMLPreElement;
if (preArea) {
const range = document.createRange();
const selection = window.getSelection();
if (selection) {
range.selectNodeContents(preArea);
range.collapse(false); // 将光标移动到内容末尾
selection.removeAllRanges();
selection.addRange(range);
}
}
}, [value]);
useEffect(() => {
if (disabled) {
vdRef.current?.disabled();
} else {
vdRef.current?.enable();
}
}, [disabled]);
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target;
if (target.className.includes('vditor--fullscreen')) {
document.body.appendChild(target);
vdFullscreen.current = true;
} else if (vdFullscreen.current) {
containerParentRef.current?.appendChild(target);
vdFullscreen.current = false;
}
}
});
observer.observe(containerRef.current);
return () => {
observer.unobserve(containerRef.current);
};
}, []);
return wrapSSR(
<div ref={containerParentRef} className={`${hashId} ${containerClassName}`}>
<div id={hashId} ref={containerRef}></div>
</div>,
);
});

View File

@ -0,0 +1,8 @@
import { connect, mapReadPretty } from '@formily/react';
import { withDynamicSchemaProps } from '@nocobase/client';
import { Display } from './Display';
import { Edit } from './Edit';
export const MarkdownVditor = withDynamicSchemaProps(connect(Edit, mapReadPretty(Display)));
export default MarkdownVditor;

View File

@ -0,0 +1,15 @@
import { genStyleHook } from '@nocobase/client';
export default genStyleHook('nb-field-markdown-vditor', (token) => {
const { componentCls } = token;
return {
[componentCls]: {
'.vditor-reset': { fontSize: `${token.fontSize}px !important` },
'.vditor': { borderRadius: 8 },
'.vditor .vditor-content': { borderRadius: '0 0 8px 8px', overflow: 'hidden' },
'.vditor .vditor-toolbar': { paddingLeft: ' 16px !important', borderRadius: '8px 8px 0 0' },
'.vditor .vditor-content .vditor-ir .vditor-reset': { paddingLeft: ' 16px !important' },
},
};
});

View File

@ -0,0 +1,22 @@
import { Plugin } from '@nocobase/client';
import { MarkdownVditor } from './components';
import { MarkdownVditorFieldInterface } from './interfaces/markdown-vditor';
import 'vditor/dist/index.css';
import katex from 'katex';
export class PluginFieldMarkdownVditorClient extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {
this.app.addComponents({ MarkdownVditor });
this.initKatexDependency();
this.app.dataSourceManager.addFieldInterfaces([MarkdownVditorFieldInterface]);
}
initKatexDependency() {
window['katex'] = katex;
}
}
export default PluginFieldMarkdownVditorClient;

View File

@ -0,0 +1,106 @@
import { CollectionFieldInterface, interfacesProperties } from '@nocobase/client';
import { generateNTemplate } from '../locale';
import { ISchema } from '@formily/react';
const { defaultProps, operators } = interfacesProperties;
export const defaultToolbar = [
'headings',
'bold',
'italic',
'strike',
'link',
'list',
'ordered-list',
'check',
'quote',
'line',
'code',
'inline-code',
'upload',
'fullscreen',
];
export class MarkdownVditorFieldInterface extends CollectionFieldInterface {
name = 'vditor';
type = 'object';
group = 'media';
order = 1;
title = generateNTemplate('Vditor');
sortable = true;
default = {
type: 'text',
length: 'long',
uiSchema: {
type: 'string',
'x-component': 'MarkdownVditor',
},
};
properties = {
...defaultProps,
'uiSchema.x-component-props.fileCollection': {
type: 'string',
title: generateNTemplate('File collection'),
'x-component': 'CollectionSelect',
'x-component-props': { filter: (collection) => collection?.options?.template === 'file' },
'x-decorator': 'FormItem',
default: '',
'x-reactions': {
fulfill: {
schema: {
description: generateNTemplate('Used to store files uploaded in the Markdown editor'),
},
},
},
},
'uiSchema.x-component-props.toolbar': {
type: 'array',
title: generateNTemplate('Toolbar'),
'x-component': 'Select',
'x-component-props': {
mode: 'multiple',
},
'x-decorator': 'FormItem',
default: defaultToolbar,
enum: [
{ value: 'emoji', label: generateNTemplate('Emoji') },
{ value: 'headings', label: generateNTemplate('Headings') },
{ value: 'bold', label: generateNTemplate('Bold') },
{ value: 'italic', label: generateNTemplate('Italic') },
{ value: 'strike', label: generateNTemplate('Strike') },
{ value: 'line', label: generateNTemplate('Line') },
{ value: 'quote', label: generateNTemplate('Quote') },
{ value: 'list', label: generateNTemplate('List') },
{ value: 'ordered-list', label: generateNTemplate('OrderedList') },
{ value: 'check', label: generateNTemplate('Check') },
{ value: 'outdent', label: generateNTemplate('Outdent') },
{ value: 'indent', label: generateNTemplate('Indent') },
{ value: 'code', label: generateNTemplate('Code') },
{ value: 'inline-code', label: generateNTemplate('InlineCode') },
{ value: 'insert-after', label: generateNTemplate('InsertAfter') },
{ value: 'insert-before', label: generateNTemplate('InsertBefore') },
{ value: 'undo', label: generateNTemplate('Undo') },
{ value: 'redo', label: generateNTemplate('Redo') },
{ value: 'upload', label: generateNTemplate('Upload') },
{ value: 'link', label: generateNTemplate('Link') },
{ value: 'record', label: generateNTemplate('Record') },
{ value: 'table', label: generateNTemplate('Table') },
{ value: 'edit-mode', label: generateNTemplate('EditMode') },
{ value: 'both', label: generateNTemplate('Both') },
{ value: 'preview', label: generateNTemplate('Preview') },
{ value: 'fullscreen', label: generateNTemplate('Fullscreen') },
{ value: 'outline', label: generateNTemplate('Outline') },
],
},
};
schemaInitialize(schema: ISchema, { block }) {
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {};
schema['x-component-props']['ellipsis'] = true;
}
}
filterable = {
operators: operators.number,
};
titleUsable = true;
}

View File

@ -0,0 +1,7 @@
import { tval } from '@nocobase/client';
const NAMESPACE = 'field-markdown-vditor';
export function generateNTemplate(key: string) {
return tval(key, { ns: NAMESPACE })
}

View File

@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,33 @@
export default {
"Vditor": "Markdown(Vditor)",
"File collection": "File collection",
"Used to store files uploaded in the Markdown editor": "Used to store files uploaded in the Markdown editor",
"Toolbar": "Editor toolbar configuration",
"Emoji": "Emoji",
"Headings": "Headings",
"Bold": "Bold",
"Italic": "Italic",
"Strike": "Strike",
"Record": "Start Record/End Record",
"Line": "Line",
"Quote": "Quote",
"List": "List",
"OrderedList": "Order List",
"Check": "Task List",
"Outdent": "Outdent",
"Indent": "Indent",
"Code": "Code Block",
"InlineCode": "Inline Code",
"InsertAfter": "Insert Line After",
"InsertBefore": "Insert Line Before",
"Undo": "Undo",
"Redo": "Redo",
"Upload": "Upload image or file",
"Link": "Link",
"Table": "Table",
"EditMode": "Edit Mode",
"Both": "Editor & Preview",
"Preview": "Preview",
"Fullscreen": "Toggle Fullscreen",
"Outline": "Outline"
}

View File

@ -0,0 +1,33 @@
export default {
"Vditor": "Markdown(Vditor)",
"File collection": "파일 데이터 테이블",
"Used to store files uploaded in the Markdown editor": "Markdown 편집기에 업로드된 파일을 저장하는 데 사용됩니다",
"Toolbar": "편집기 도구 모음 구성",
"Emoji": "이모지",
"Headings": "제목크기",
"Bold": "굵게",
"Italic": "기울임꼴",
"Strike": "취소선",
"Record": "녹음시작/녹음종료",
"Line": "문단나눔",
"Quote": "인용단락",
"List": "순서없는 목록",
"OrderedList": "순서있는 목록",
"Check": "체크박스",
"Outdent": "내어쓰기",
"Indent": "들여쓰기",
"Code": "코드블럭삽입",
"InlineCode": "인라인코드",
"InsertAfter": "블락 뒤로 입력",
"InsertBefore": "블락 앞으로 입력",
"Undo": "취소하기",
"Redo": "되돌리기",
"Upload": "이미지 업로드하기",
"Link": "링크",
"Table": "표삽입",
"EditMode": "편집모드",
"Both": "에디터 & 미리보기",
"Preview": "미리보기",
"Fullscreen": "전체화면",
"Outline": "개요"
}

View File

@ -0,0 +1,33 @@
export default {
"Vditor": "Markdown(Vditor)",
"File collection": "文件数据表",
"Used to store files uploaded in the Markdown editor": "用于存储在Markdown编辑器中上传的文件",
"Toolbar": "编辑器工具栏配置",
"Emoji": "表情",
"Headings": "标题",
"Bold": "粗体",
"Italic": "斜体",
"Strike": "删除线",
"Record": "开始录音/结束录音",
"Line": "分割线",
"Quote": "引用",
"List": "无序列表",
"OrderedList": "有序列表",
"Check": "任务列表",
"Outdent": "列表反向缩进",
"Indent": "列表缩进",
"Code": "代码块",
"InlineCode": "行内代码",
"InsertAfter": "末尾插入行",
"InsertBefore": "起始插入行",
"Undo": "撤销",
"Redo": "重做",
"Upload": "上传图片或文件",
"Link": "链接",
"Table": "表格",
"EditMode": "切换编辑模式",
"Both": "编辑 & 预览",
"Preview": "预览",
"Fullscreen": "全屏切换",
"Outline": "大纲"
}

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,7 @@
import { DataTypes, Field } from '@nocobase/database';
export class MarkdownVditorField extends Field {
get dataType() {
return DataTypes.TEXT;
}
}

View File

@ -0,0 +1,19 @@
import { Plugin } from '@nocobase/server';
export class PluginFieldMarkdownVditorServer extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginFieldMarkdownVditorServer;

View File

@ -35,6 +35,7 @@
"@nocobase/plugin-kanban": "0.21.0-alpha.15",
"@nocobase/plugin-localization-management": "0.21.0-alpha.15",
"@nocobase/plugin-logger": "0.21.0-alpha.15",
"@nocobase/plugin-field-markdown-vditor": "0.21.0-alpha.15",
"@nocobase/plugin-map": "0.21.0-alpha.15",
"@nocobase/plugin-mobile-client": "0.21.0-alpha.15",
"@nocobase/plugin-mock-collections": "0.21.0-alpha.15",

View File

@ -60,6 +60,7 @@ export class PresetNocoBase extends Plugin {
'api-doc>=0.13.0-alpha.1',
'cas>=0.13.0-alpha.5',
'sms-auth>=0.10.0-alpha.2',
'field-markdown-vditor>=0.21.0-alpha.11',
];
splitNames(name: string) {