feat: 2.0 markdown block (#7613)

* feat: markdown& liquid

* feat: markdown block

* feat: markdown block

* fix: bug

* refactor: markdown vditor

* refactor: update package.json

* fix: bug

* fix: bug

* Merge branch 'develop' into task-6928

* fix: bug

* fix: bug

* fix: style improve

* fix: style improve

* fix: style improve

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug
This commit is contained in:
Katherine
2025-10-24 14:30:07 +08:00
committed by GitHub
parent f676ea9d21
commit beb05b31d6
35 changed files with 1432 additions and 53 deletions
+3 -1
View File
@@ -76,7 +76,9 @@
"react-to-print": "^2.14.7",
"sanitize-html": "2.13.0",
"tabulator-tables": "^6.3.1",
"use-deep-compare-effect": "^1.8.1"
"use-deep-compare-effect": "^1.8.1",
"vditor": "^3.10.3",
"liquidjs": "^10.0.0"
},
"peerDependencies": {
"react": ">=18.0.0",
@@ -0,0 +1,102 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Liquid } from 'liquidjs';
export class LiquidEngine extends Liquid {
constructor(options = {}) {
super({
extname: '.liquid',
cache: true,
...options,
});
// 注册国际化过滤器
this.registerFilter('t', (key, locale = 'en', dict = {}) => {
if (!key) return '';
if (!dict) return key;
// 优先当前语言,否则 fallback 到英文
return dict[key]?.[locale] || dict[key]?.['en'] || key;
});
// (可选)注册一个日志过滤器,方便调试
this.registerFilter('log', (value) => {
console.log('[Liquid log]', value);
return value;
});
}
/**
* 将路径数组转为 Liquid 模板上下文对象
* @param {string[]} paths - 如 ['ctx.user.name', 'ctx.order.total']
* @returns {object} 形如 { user: { name: '{{ctx.user.name}}' }, order: {...} }
*/
transformLiquidContext(paths = []) {
const result = {};
for (const fullPath of paths) {
const path = fullPath.replace(/^ctx\./, '');
const keys = path.split('.');
let current = result;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const isLast = i === keys.length - 1;
if (isLast) {
current[key] = `{{${fullPath}}}`;
} else {
current[key] = current[key] || {};
current = current[key];
}
}
}
return result;
}
/**
* 渲染模板
* @param {string} template - Liquid 模板字符串
* @param {object} context - 模板上下文变量
* @returns {Promise<string>} 渲染后的字符串
*/
async render(template, context = {}) {
try {
return await this.parseAndRender(template, context);
} catch (err) {
console.error('[Liquid] 模板解析失败:', err);
return `<pre style="color:red;">Liquid 模板错误:${err.message}</pre>`;
}
}
/**
* 合并步骤:获取变量 -> 构建 context -> 解析 -> 渲染
* @param {string} template Liquid 模板字符串
* @param {context} ctx flowContext
*/
async renderWithFullContext(template, ctx) {
try {
// 1️⃣ 分析模板中的变量
const vars = await this.fullVariables(template);
// 2️⃣ 构造 Liquid context
const liquidContext = this.transformLiquidContext(vars);
// 3️⃣ 只解析变量
const resolvedCtx = await ctx.resolveJsonTemplate(liquidContext);
// 4️⃣ 渲染模板
return await this.render(template, { ctx: resolvedCtx });
} catch (err) {
console.error('[Liquid] renderWithFullContext 错误:', err);
return `<pre style="color:red;">Liquid 渲染错误:${err.message}</pre>`;
}
}
}
@@ -0,0 +1,183 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Popover } from 'antd';
import { css } from '@emotion/css';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import Vditor from 'vditor';
import { useCDN } from './useCDN';
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>(null);
const { wrapSSR, componentCls, hashId } = useStyle();
const cdn = useCDN();
useEffect(() => {
Vditor.preview(containerRef.current, props.value ?? '', {
mode: 'light',
cdn,
});
setTimeout(() => {
containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', () => {
openCustomPreview(img.src);
});
});
}, 0);
}, [props.value]);
return wrapSSR(
<span className={`${hashId} ${componentCls}`}>
<span ref={containerRef} style={{ border: 'none', ...(props?.style ?? {}) }} />
</span>,
);
}
function openCustomPreview(src: string) {
if (document.getElementById('custom-image-preview')) return;
// 创建容器
const overlay = document.createElement('span');
overlay.id = 'custom-image-preview';
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
backgroundColor: 'rgba(0,0,0,0.85)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '9999',
cursor: 'zoom-out',
});
const img = document.createElement('img');
img.src = src;
Object.assign(img.style, {
maxWidth: '90%',
maxHeight: '90%',
borderRadius: '8px',
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
transition: 'transform 0.2s',
cursor: 'zoom-out',
});
overlay.addEventListener('click', () => {
document.body.removeChild(overlay);
});
overlay.appendChild(img);
document.body.appendChild(overlay);
}
export const Display = (props) => {
const { value, textOnly = true } = props;
const cdn = useCDN();
const [popoverVisible, setPopoverVisible] = useState(false);
const [ellipsis, setEllipsis] = useState(false);
const [text, setText] = useState('');
const elRef = useRef<HTMLDivElement>();
useEffect(() => {
if (!props.value) return;
if (textOnly) {
Vditor.md2html(props.value, {
mode: 'light',
cdn,
})
.then((html) => {
setText(convertToText(html));
})
.catch(() => setText(''));
}
}, [props.value, textOnly]);
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}
getPopupContainer={() => document.getElementsByClassName('ant-drawer-content')?.[0] as HTMLElement}
onOpenChange={(visible) => {
setPopoverVisible(ellipsis && visible);
}}
overlayStyle={{ maxWidth: 400, maxHeight: 450, overflow: 'auto' }}
content={<DisplayInner value={value} />}
>
<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);
}
}}
>
{textOnly ? (
text
) : (
<div
className={css`
.vditor-reset {
white-space: nowrap;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
`}
>
<DisplayInner value={value} />
</div>
)}
</div>
</Popover>
);
}
if (textOnly) {
return text;
}
return <DisplayInner value={value} />;
};
@@ -0,0 +1,368 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useAPIClient, useCompile, usePlugin, useZIndexContext, getZIndex } from '@nocobase/client';
import { Button } from 'antd';
import { css } from '@emotion/css';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { FlowContextSelector, useFlowContext, useFlowModel } from '@nocobase/flow-engine';
import React, { useEffect, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { useCDN } from './useCDN';
import useStyle from './style';
const defaultToolbar = [
'headings',
'bold',
'italic',
'strike',
'link',
'list',
'ordered-list',
'check',
'quote',
'line',
'code',
'inline-code',
'upload',
'fullscreen',
];
const NAMESPACE = 'block-markdown';
const locales = ['en_US', 'fr_FR', 'pt_BR', 'ja_JP', 'ko_KR', 'ru_RU', 'sv_SE', 'zh_CN', 'zh_TW'];
const Edit = (props) => {
const { disabled, onChange, value, fileCollection, toolbar, vditorRef } = props;
const [editorReady, setEditorReady] = useState(false);
const vdRef = useRef<Vditor>();
const vdFullscreen = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const containerParentRef = useRef<HTMLDivElement>(null);
const apiClient = useAPIClient();
const cdn = useCDN();
const { wrapSSR, hashId, componentCls: containerClassName } = useStyle();
const locale = apiClient.auth.locale || 'en-US';
const fileManagerPlugin: any = usePlugin('@nocobase/plugin-file-manager');
const compile = useCompile();
const compileRef = useRef(compile);
compileRef.current = compile;
const { t } = useTranslation();
const parentZIndex = useZIndexContext();
const zIndex = getZIndex('drawer', parentZIndex + 1000, 0);
const lang: any = useMemo(() => {
const currentLang = locale.replace(/-/g, '_');
if (locales.includes(currentLang)) {
return currentLang;
}
return 'en_US';
}, [locale]);
useEffect(() => {
if (!containerRef.current) return;
const toolbarConfig = toolbar ?? defaultToolbar;
const vditor = new Vditor(containerRef.current, {
value: value ?? '',
lang,
cache: { enable: false },
undoDelay: 0,
preview: { math: { engine: 'KaTeX' } },
toolbar: toolbarConfig,
fullscreen: { index: 1200 },
cdn,
minHeight: 200,
after: () => {
vdRef.current = vditor;
setEditorReady(true); // Notify that the editor is ready
vditor.setValue(value ?? '');
if (disabled) {
vditor.disabled();
} else {
vditor.enable();
}
},
input(value) {
onChange(value);
},
upload: {
multiple: false,
fieldName: 'file',
async handler(files: File[]) {
const file = files[0];
// Need to ensure focus is in the current input box before uploading
vditor.focus();
const { data: checkData } = await apiClient.resource('vditor').check({
fileCollectionName: fileCollection,
});
if (!checkData?.data?.isSupportToUploadFiles) {
vditor.tip(
t('vditor.uploadError.message', { ns: NAMESPACE, storageTitle: checkData.data.storage?.title }),
0,
);
return;
}
vditor.tip(t('uploading'), 0);
const { data, errorMessage } = await fileManagerPlugin.uploadFile({
file,
fileCollectionName: fileCollection,
storageId: checkData?.data?.storage?.id,
storageType: checkData?.data?.storage?.type,
storageRules: checkData?.data?.storage?.rules,
});
if (errorMessage) {
vditor.tip(compileRef.current(errorMessage), 3000);
return;
}
if (!data) {
vditor.tip(t('Response data is empty', { ns: NAMESPACE }), 3000);
return;
}
const fileName = data.filename;
const fileUrl = data.url;
// Check if the uploaded file is an image
const isImage = file.type.startsWith('image/');
if (isImage) {
// Insert as an image - will be displayed in the editor
vditor.insertValue(`![${fileName}](${fileUrl})`);
} else {
// For non-image files, insert as a download link
vditor.insertValue(`[${fileName}](${fileUrl})`);
}
// hide the tip
vditor.tip(t(''), 10);
return null;
},
},
});
vditorRef.current = vditor;
return () => {
vdRef.current?.destroy();
vdRef.current = undefined;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolbar?.join(',')]);
useEffect(() => {
if (editorReady && vdRef.current) {
const editor = vdRef.current;
if (value !== editor.getValue()) {
editor.setValue(value ?? '');
// editor.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); // Move cursor to the end
selection.removeAllRanges();
selection.addRange(range);
}
}
}
}
}, [value, editorReady]);
useEffect(() => {
if (editorReady && vdRef.current) {
if (disabled) {
vdRef.current.disabled();
} else {
vdRef.current.enable();
}
}
}, [disabled, editorReady]);
useEffect(() => {
if (!containerParentRef.current) return;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof HTMLElement && node.classList.contains('vditor-img')) {
// 移动图片预览层到弹窗容器
containerParentRef.current.appendChild(node);
node.style.zIndex = String(zIndex);
}
}
}
});
observer.observe(document.body, { childList: true });
return () => {
observer.disconnect();
};
}, [zIndex]);
useLayoutEffect(() => {
if (!containerRef.current) return;
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 ref={containerRef}></div>
</div>,
);
};
export interface MarkdownWithContextSelectorProps {
value?: string;
onChange?: (v: string) => void;
placeholder?: string;
rows?: number;
style?: React.CSSProperties;
}
/**
* markdown 与变量选择器的组合,紧凑排版,边框无缝拼接。
*/
export const MarkdownWithContextSelector: React.FC<MarkdownWithContextSelectorProps> = ({
value = '',
onChange,
placeholder,
style,
}) => {
const flowCtx = useFlowContext();
const [innerValue, setInnerValue] = useState<string>(value || '');
const ref = useRef<TextAreaRef>(null);
const isConfigMode = !!flowCtx.model.flowEngine?.flowSettings?.enabled;
// 外部 value 变化时同步内部显示
useEffect(() => {
setInnerValue(value || '');
}, [value]);
const handleTextChange = useCallback(
(e) => {
const next = e ?? '';
setInnerValue(next);
onChange?.(next);
},
[onChange],
);
// 将指定文本插入到当前光标位置
const insertAtCaret = useCallback(
(toInsert: string) => {
const editor = ref.current as any;
if (!editor) {
console.warn('Vditor 尚未初始化,无法插入文本');
return;
}
// 🔹 在当前光标位置插入文本
editor.insertValue(toInsert);
// 🔹 同步外部状态
const next = editor.getValue();
setInnerValue(next);
onChange?.(next);
// 🔹 保持聚焦
requestAnimationFrame(() => {
editor.focus();
});
},
[innerValue, onChange],
);
const handleVariableSelected = useCallback(
(varValue: string) => {
if (!varValue) return;
insertAtCaret(varValue);
},
[insertAtCaret],
);
// 使用函数形式提供变量树,保证与运行时上下文一致
const metaTree = useMemo(() => () => flowCtx.getPropertyMetaTree?.(), [flowCtx]);
return (
<div style={{ position: 'relative', width: '100%', ...style }}>
<Edit
vditorRef={ref}
value={innerValue}
onChange={handleTextChange}
placeholder={placeholder}
style={{ width: '100%' }}
/>
{isConfigMode && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
zIndex: 1,
lineHeight: 0,
}}
>
{/* 参考 1.0:小号按钮 + 非 hover 去掉右/上边框,背景透明,贴合右上角 */}
<FlowContextSelector metaTree={metaTree} onChange={(val) => handleVariableSelected(val)} onlyLeafSelectable>
<Button
type="default"
className={css`
font-style: italic;
font-family: 'New York, Times New Roman, Times, serif';
line-height: 1;
&:not(:hover) {
border-right-color: transparent;
border-top-color: transparent;
background-color: transparent;
}
`}
>
x
</Button>
</FlowContextSelector>
</div>
)}
</div>
);
};
@@ -0,0 +1,44 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Display } from './Display';
import { MarkdownWithContextSelector as Edit } from './Edit';
export class Markdown {
/**
* 渲染 Markdown
* @param {string} text - Markdown 文本
* @param {object} props - 其他属性
* @returns {JSX.Element}
*/
render(text, props) {
if (!text) return null;
try {
return <Display value={text} {...props} />;
} catch (err) {
console.error('渲染失败:', err);
return <pre style={{ color: 'red' }}>Markdown {err.message}</pre>;
}
}
/**
* 渲染可编辑的 Markdown 组件
* @param {object} props - 编辑器属性
* @returns {JSX.Element}
*/
edit(props) {
try {
return <Edit {...props} />;
} catch (err) {
return <pre style={{ color: 'red' }}>Markdown {err.message}</pre>;
}
}
}
@@ -0,0 +1,27 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { genStyleHook } from '../../../schema-component/antd/__builtins__';
export default genStyleHook('nb-markdown-vditor', (token) => {
const { componentCls } = token;
return {
[componentCls]: {
'.vditor-reset': { fontSize: `${token.fontSize}px !important`, color: 'unset', padding: `10px !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' },
'.vditor-ir pre.vditor-reset': {
backgroundColor: `${token.colorBgContainer}!important`,
},
},
};
});
@@ -0,0 +1,22 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { usePlugin } from '@nocobase/client';
import { useFlowEngine } from '@nocobase/flow-engine';
export const useCDN = () => {
const flowEngine = useFlowEngine();
const app = flowEngine.context.app;
const plugin: any = usePlugin('@nocobase/plugin-block-markdown');
if (!plugin.dependencyLoaded) {
plugin.initVditorDependency(app);
plugin.dependencyLoaded = true;
}
return plugin.getCDN(app);
};
+15
View File
@@ -17,6 +17,8 @@ import { FlowRoute } from './FlowPage';
import * as models from './models';
import * as filterFormActions from './models/blocks/filter-manager/flow-actions';
import { DynamicFlowsIcon } from './components/DynamicFlowsIcon';
import { Markdown } from './common/Markdown/Markdown';
import { LiquidEngine } from './common/Liquid';
export class PluginFlowEngine extends Plugin {
async load() {
@@ -44,6 +46,19 @@ export class PluginFlowEngine extends Plugin {
},
sort: 0,
});
// 实例化一个全局 Markdown 解析器
const markdownInstance = new Markdown();
this.flowEngine.context.defineProperty('markdown', {
get: () => markdownInstance,
});
// 创建全局实例
const liquidInstance = new LiquidEngine();
// 注册到全局上下文
this.flowEngine.context.defineProperty('liquid', {
get: () => liquidInstance,
});
}
}
@@ -7,22 +7,79 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { useParseMarkdown, convertToText } from './util';
import { useMarkdownStyles } from './style';
import { Tooltip } from 'antd';
export const DisplayMarkdown = (props) => {
const { textOnly } = props;
const { textOnly, overflowMode, style } = props;
const markdownClass = useMarkdownStyles();
const { html = '' } = useParseMarkdown(props.value);
const text = convertToText(html);
const value = (
const text: any = convertToText(html);
const isEllipsis = overflowMode === 'ellipsis';
const contentRef = useRef<HTMLDivElement>(null);
const [isOverflowed, setIsOverflowed] = useState(false);
// 检测内容是否溢出
useEffect(() => {
if (contentRef.current) {
const el = contentRef.current;
const overflowed = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
setIsOverflowed(overflowed);
}
}, [html, style, overflowMode]);
// 通用样式(用于 ellipsis 模式)
const baseStyle: React.CSSProperties = {
...(style || {}),
...(isEllipsis
? {
display: '-webkit-box',
WebkitLineClamp: 1, // 可改为多行省略
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'normal',
wordBreak: 'break-word',
cursor: 'default',
}
: {}),
};
const content = (
<div
className={` ${markdownClass} nb-markdown nb-markdown-default nb-markdown-table`}
dangerouslySetInnerHTML={{ __html: html }}
style={props.style}
ref={contentRef}
className={`${markdownClass} nb-markdown`}
dangerouslySetInnerHTML={{ __html: textOnly ? text : html }}
style={baseStyle}
/>
);
return <>{textOnly ? text : value}</>;
return isEllipsis && isOverflowed ? (
<Tooltip
title={
<div
dangerouslySetInnerHTML={{ __html: html }}
style={{
maxHeight: 500,
overflowY: 'auto',
padding: 10,
color: '#000',
}}
/>
}
overlayInnerStyle={{
background: '#fff',
color: '#000',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
color="#fff"
>
{content}
</Tooltip>
) : (
content
);
};
@@ -37,6 +37,9 @@ export class DisplayTitleFieldModel extends FieldModel {
rootClassName: css`
.ant-tooltip-inner {
color: #000;
max-height: 500px;
overflow-y: auto;
padding: 10px;
}
`,
color: '#fff',
@@ -0,0 +1,2 @@
/node_modules
/src
@@ -0,0 +1 @@
# @nocobase/plugin-block-markdown
@@ -0,0 +1,15 @@
import { defineConfig } from '@nocobase/build';
import fs from 'fs/promises';
import path from 'path';
const vditor = path.dirname(require.resolve('vditor'));
export default defineConfig({
afterBuild: async (log) => {
log('coping vditor dist');
await fs.cp(vditor, path.resolve(__dirname, 'dist/client/vditor/dist'), {
recursive: true,
force: true,
});
},
});
@@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';
@@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');
@@ -0,0 +1,21 @@
{
"name": "@nocobase/plugin-block-markdown",
"version": "2.0.0-alpha.21",
"main": "dist/server/index.js",
"displayName": "Block: Markdown",
"displayName.zh-CN": "Markdown",
"description": "Provide Markdown block",
"description.zh-CN": "提供Markdown 区块",
"license": "AGPL-3.0",
"homepage": "https://docs.nocobase.com/handbook/block-markdown",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/block-markdown",
"dependencies": {},
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
},
"keywords": [
"Blocks"
]
}
@@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';
@@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');
@@ -0,0 +1,249 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}
@@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';
@@ -0,0 +1,21 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { tExpr as _tExpr, useFlowEngine } from '@nocobase/flow-engine';
// @ts-ignore
import pkg from './../../package.json';
export function useT() {
const engine = useFlowEngine();
return (str: string) => engine.context.t(str, { ns: [pkg.name, 'client'] });
}
export function tExpr(key: string) {
return _tExpr(key, { ns: [pkg.name, 'client'] });
}
@@ -0,0 +1,85 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { BlockModel, css } from '@nocobase/client';
import { escapeT } from '@nocobase/flow-engine';
import { Card } from 'antd';
import React from 'react';
export class MarkdownBlockModel extends BlockModel {
render() {
const { content } = this.props;
return (
<Card
className={css`
.ant-card-body {
padding: 0px 10px;
}
`}
>
{content}
</Card>
);
}
}
MarkdownBlockModel.define({
label: escapeT('Markdown'),
});
MarkdownBlockModel.registerFlow({
key: 'markdownBlockSettings',
title: escapeT('Markdown block settings', { ns: 'block-markdown' }),
steps: {
editMarkdown: {
title: escapeT('Edit markdown'),
uiSchema(ctx) {
const t = ctx.t;
const descriptionContent = (
<>
<span style={{ marginLeft: '.25em' }} className={'ant-formily-item-extra'}>
{t('Syntax references')}:
</span>
<a href={`https://shopify.github.io/liquid/basics/introduction/`} target="_blank" rel="noreferrer">
Liquid
</a>
</>
);
return {
content: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': (props) => ctx.markdown.edit(props),
description: descriptionContent,
},
};
},
useRawParams: true,
defaultParams: {
content: "{{ 'This is a demo text, **supports Markdown syntax**.' | t: locale, i18n }}",
},
async handler(ctx, params) {
const content = params.content;
try {
const result = await ctx.liquid.renderWithFullContext(content, ctx);
// 解析 Markdown
const mdContent = ctx.markdown.render(ctx.t(result), { textOnly: false });
ctx.model.setProps({
content: mdContent,
});
} catch (error) {
ctx.model.setProps({
content: <pre>{`渲染失败: ${error}`}</pre>,
});
}
},
},
},
});
@@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { MarkdownBlockModel } from './MarkdownBlockModel';
export { MarkdownBlockModel };
@@ -0,0 +1,52 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/client';
import { MarkdownBlockModel } from './models';
export class PluginBlockMarkdownClient extends Plugin {
async load() {
this.flowEngine.registerModels({
MarkdownBlockModel,
});
}
getCDN() {
return this.app.getPublicPath() + 'static/plugins/@nocobase/plugin-block-markdown/dist/client/vditor';
}
initVditorDependency() {
const cdn = this.getCDN();
try {
const vditorDepdencePrefix = 'plugin-block-markdown-dep';
const vditorDepdence = {
[`${vditorDepdencePrefix}.katex`]: `${cdn}/dist/js/katex/katex.min.js?v=0.16.9`,
[`${vditorDepdencePrefix}.ABCJS`]: `${cdn}/dist/js/abcjs/abcjs_basic.min`,
[`${vditorDepdencePrefix}.plantumlEncoder`]: `${cdn}/dist/js/plantuml/plantuml-encoder.min`,
[`${vditorDepdencePrefix}.echarts`]: `${cdn}/dist/js/echarts/echarts.min`,
[`${vditorDepdencePrefix}.flowchart`]: `${cdn}/dist/js/flowchart.js/flowchart.min`,
[`${vditorDepdencePrefix}.Viz`]: `${cdn}/dist/js/graphviz/viz`,
[`${vditorDepdencePrefix}.mermaid`]: `${cdn}/dist/js/mermaid/mermaid.min`,
};
this.app.requirejs.require.config({
waitSeconds: 120,
paths: vditorDepdence,
});
Object.keys(vditorDepdence).forEach((key) => {
this.app.requirejs.require([key], (m) => {
window[key.split('.')[1]] = m;
});
});
} catch (e) {
console.log('initVditorDependency failed', e);
}
}
}
export default PluginBlockMarkdownClient;
@@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './server';
export { default } from './server';
@@ -0,0 +1 @@
{ "Markdown block settings": "Markdown 区块设置" }
@@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';
@@ -0,0 +1,28 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/server';
export class PluginBlockMarkdownServer extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginBlockMarkdownServer;
@@ -1,38 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { DisplayItemModel, escapeT } from '@nocobase/flow-engine';
import { DisplayTitleFieldModel, tval } from '@nocobase/client';
import { Display } from './components/Display';
export class DisplayVditorFieldModel extends DisplayTitleFieldModel {
public renderComponent(value) {
if (!value) {
return;
}
return <Display value={value} ellipsis={this.props.textOnly} />;
}
}
DisplayVditorFieldModel.define({
label: escapeT('MarkdownVditor'),
});
DisplayVditorFieldModel.registerFlow({
key: 'markdownVditorSettings',
title: tval('Content settings'),
sort: 200,
steps: {
renderMode: {
use: 'renderMode',
},
},
});
DisplayItemModel.bindModelToInterface('DisplayVditorFieldModel', ['vditor'], { isDefault: true });
@@ -9,10 +9,9 @@
import { Plugin } from '@nocobase/client';
import 'vditor/dist/index.css';
// import { MarkdownVditor } from './components';
import { lazy } from '@nocobase/client';
import { VditorFieldModel } from './VditorFieldModel';
import { DisplayVditorFieldModel } from './DisplayVditorFieldModel';
import { VditorFieldModel } from './models/VditorFieldModel';
import { DisplayVditorFieldModel } from './models/DisplayVditorFieldModel';
const { MarkdownVditor } = lazy(() => import('./components'), 'MarkdownVditor');
import { MarkdownVditorFieldInterface } from './interfaces/markdown-vditor';
@@ -0,0 +1,68 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { DisplayItemModel, escapeT } from '@nocobase/flow-engine';
import { DisplayTitleFieldModel, tval } from '@nocobase/client';
import React, { useState, useEffect } from 'react';
const Display = ({ value, markdown, liquid, t, textOnly, ctx, overflowMode }) => {
const [content, setContent] = useState(null);
useEffect(() => {
if (!value) return;
(async () => {
try {
const result = await liquid.renderWithFullContext(value, ctx);
const html = markdown.render(t(result), { ellipsis: overflowMode === 'ellipsis', textOnly });
setContent(html);
} catch (err) {
setContent(`<pre style="color:red;"> 渲染错误: ${err.message}</pre>`);
}
})();
}, [value, textOnly, overflowMode]);
return content;
};
export class DisplayVditorFieldModel extends DisplayTitleFieldModel {
public renderComponent(value) {
if (!value) return null;
const { markdown, liquid, t } = this.context;
const { textOnly, overflowMode } = this.props;
return (
<Display
value={value}
markdown={markdown}
liquid={liquid}
t={t}
textOnly={textOnly}
ctx={this.context}
overflowMode={overflowMode}
/>
);
}
}
DisplayVditorFieldModel.define({
label: escapeT('MarkdownVditor'),
});
DisplayVditorFieldModel.registerFlow({
key: 'markdownVditorSettings',
title: tval('Content settings'),
sort: 200,
steps: {
renderMode: {
use: 'renderMode',
},
},
});
DisplayItemModel.bindModelToInterface('DisplayVditorFieldModel', ['vditor'], { isDefault: true });
@@ -7,13 +7,13 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FieldModel } from '@nocobase/client';
import React from 'react';
import { largeField, EditableItemModel } from '@nocobase/flow-engine';
import { Edit } from './components/Edit';
@largeField()
export class VditorFieldModel extends FieldModel {
render() {
return <Edit {...this.props} />;
const markdown = this.context.markdown;
return markdown.edit(this.props);
}
}
EditableItemModel.bindModelToInterface('VditorFieldModel', ['vditor'], { isDefault: true });
+2
View File
@@ -24,6 +24,7 @@
"@nocobase/plugin-block-grid-card": "2.0.0-alpha.21",
"@nocobase/plugin-block-iframe": "2.0.0-alpha.21",
"@nocobase/plugin-block-list": "2.0.0-alpha.21",
"@nocobase/plugin-block-markdown": "2.0.0-alpha.21",
"@nocobase/plugin-block-reference": "2.0.0-alpha.21",
"@nocobase/plugin-block-template": "2.0.0-alpha.21",
"@nocobase/plugin-block-workbench": "2.0.0-alpha.21",
@@ -113,6 +114,7 @@
"@nocobase/plugin-block-workbench",
"@nocobase/plugin-block-list",
"@nocobase/plugin-block-grid-card",
"@nocobase/plugin-block-markdown",
"@nocobase/plugin-calendar",
"@nocobase/plugin-client",
"@nocobase/plugin-collection-sql",