Skip to content

Commit beb05b3

Browse files
authored
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
1 parent f676ea9 commit beb05b3

File tree

35 files changed

+1432
-53
lines changed

35 files changed

+1432
-53
lines changed

packages/core/client/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@
7676
"react-to-print": "^2.14.7",
7777
"sanitize-html": "2.13.0",
7878
"tabulator-tables": "^6.3.1",
79-
"use-deep-compare-effect": "^1.8.1"
79+
"use-deep-compare-effect": "^1.8.1",
80+
"vditor": "^3.10.3",
81+
"liquidjs": "^10.0.0"
8082
},
8183
"peerDependencies": {
8284
"react": ">=18.0.0",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* This file is part of the NocoBase (R) project.
3+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
4+
* Authors: NocoBase Team.
5+
*
6+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7+
* For more information, please refer to: https://www.nocobase.com/agreement.
8+
*/
9+
import { Liquid } from 'liquidjs';
10+
11+
export class LiquidEngine extends Liquid {
12+
constructor(options = {}) {
13+
super({
14+
extname: '.liquid',
15+
cache: true,
16+
...options,
17+
});
18+
19+
// 注册国际化过滤器
20+
this.registerFilter('t', (key, locale = 'en', dict = {}) => {
21+
if (!key) return '';
22+
if (!dict) return key;
23+
24+
// 优先当前语言,否则 fallback 到英文
25+
return dict[key]?.[locale] || dict[key]?.['en'] || key;
26+
});
27+
28+
// (可选)注册一个日志过滤器,方便调试
29+
this.registerFilter('log', (value) => {
30+
console.log('[Liquid log]', value);
31+
return value;
32+
});
33+
}
34+
35+
/**
36+
* 将路径数组转为 Liquid 模板上下文对象
37+
* @param {string[]} paths - 如 ['ctx.user.name', 'ctx.order.total']
38+
* @returns {object} 形如 { user: { name: '{{ctx.user.name}}' }, order: {...} }
39+
*/
40+
transformLiquidContext(paths = []) {
41+
const result = {};
42+
43+
for (const fullPath of paths) {
44+
const path = fullPath.replace(/^ctx\./, '');
45+
const keys = path.split('.');
46+
47+
let current = result;
48+
for (let i = 0; i < keys.length; i++) {
49+
const key = keys[i];
50+
const isLast = i === keys.length - 1;
51+
52+
if (isLast) {
53+
current[key] = `{{${fullPath}}}`;
54+
} else {
55+
current[key] = current[key] || {};
56+
current = current[key];
57+
}
58+
}
59+
}
60+
61+
return result;
62+
}
63+
64+
/**
65+
* 渲染模板
66+
* @param {string} template - Liquid 模板字符串
67+
* @param {object} context - 模板上下文变量
68+
* @returns {Promise<string>} 渲染后的字符串
69+
*/
70+
async render(template, context = {}) {
71+
try {
72+
return await this.parseAndRender(template, context);
73+
} catch (err) {
74+
console.error('[Liquid] 模板解析失败:', err);
75+
return `<pre style="color:red;">Liquid 模板错误:${err.message}</pre>`;
76+
}
77+
}
78+
79+
/**
80+
* 合并步骤:获取变量 -> 构建 context -> 解析 -> 渲染
81+
* @param {string} template Liquid 模板字符串
82+
* @param {context} ctx flowContext
83+
*/
84+
async renderWithFullContext(template, ctx) {
85+
try {
86+
// 1️⃣ 分析模板中的变量
87+
const vars = await this.fullVariables(template);
88+
89+
// 2️⃣ 构造 Liquid context
90+
const liquidContext = this.transformLiquidContext(vars);
91+
92+
// 3️⃣ 只解析变量
93+
const resolvedCtx = await ctx.resolveJsonTemplate(liquidContext);
94+
95+
// 4️⃣ 渲染模板
96+
return await this.render(template, { ctx: resolvedCtx });
97+
} catch (err) {
98+
console.error('[Liquid] renderWithFullContext 错误:', err);
99+
return `<pre style="color:red;">Liquid 渲染错误:${err.message}</pre>`;
100+
}
101+
}
102+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* This file is part of the NocoBase (R) project.
3+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
4+
* Authors: NocoBase Team.
5+
*
6+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7+
* For more information, please refer to: https://www.nocobase.com/agreement.
8+
*/
9+
10+
import { Popover } from 'antd';
11+
import { css } from '@emotion/css';
12+
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
13+
import Vditor from 'vditor';
14+
import { useCDN } from './useCDN';
15+
import useStyle from './style';
16+
17+
function convertToText(markdownText: string) {
18+
const content = markdownText;
19+
let temp = document.createElement('div');
20+
temp.innerHTML = content;
21+
const text = temp.innerText;
22+
temp = null;
23+
return text?.replace(/[\n\r]/g, '') || '';
24+
}
25+
26+
const getContentWidth = (element) => {
27+
if (element) {
28+
const range = document.createRange();
29+
range.selectNodeContents(element);
30+
const contentWidth = range.getBoundingClientRect().width;
31+
return contentWidth;
32+
}
33+
};
34+
35+
function DisplayInner(props: { value: string; style?: CSSProperties }) {
36+
const containerRef = useRef<HTMLDivElement>(null);
37+
const { wrapSSR, componentCls, hashId } = useStyle();
38+
const cdn = useCDN();
39+
40+
useEffect(() => {
41+
Vditor.preview(containerRef.current, props.value ?? '', {
42+
mode: 'light',
43+
cdn,
44+
});
45+
setTimeout(() => {
46+
containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => {
47+
img.style.cursor = 'zoom-in';
48+
img.addEventListener('click', () => {
49+
openCustomPreview(img.src);
50+
});
51+
});
52+
}, 0);
53+
}, [props.value]);
54+
55+
return wrapSSR(
56+
<span className={`${hashId} ${componentCls}`}>
57+
<span ref={containerRef} style={{ border: 'none', ...(props?.style ?? {}) }} />
58+
</span>,
59+
);
60+
}
61+
62+
function openCustomPreview(src: string) {
63+
if (document.getElementById('custom-image-preview')) return;
64+
65+
// 创建容器
66+
const overlay = document.createElement('span');
67+
overlay.id = 'custom-image-preview';
68+
Object.assign(overlay.style, {
69+
position: 'fixed',
70+
inset: '0',
71+
backgroundColor: 'rgba(0,0,0,0.85)',
72+
display: 'flex',
73+
alignItems: 'center',
74+
justifyContent: 'center',
75+
zIndex: '9999',
76+
cursor: 'zoom-out',
77+
});
78+
79+
const img = document.createElement('img');
80+
img.src = src;
81+
Object.assign(img.style, {
82+
maxWidth: '90%',
83+
maxHeight: '90%',
84+
borderRadius: '8px',
85+
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
86+
transition: 'transform 0.2s',
87+
cursor: 'zoom-out',
88+
});
89+
90+
overlay.addEventListener('click', () => {
91+
document.body.removeChild(overlay);
92+
});
93+
94+
overlay.appendChild(img);
95+
document.body.appendChild(overlay);
96+
}
97+
98+
export const Display = (props) => {
99+
const { value, textOnly = true } = props;
100+
const cdn = useCDN();
101+
const [popoverVisible, setPopoverVisible] = useState(false);
102+
const [ellipsis, setEllipsis] = useState(false);
103+
104+
const [text, setText] = useState('');
105+
106+
const elRef = useRef<HTMLDivElement>();
107+
useEffect(() => {
108+
if (!props.value) return;
109+
if (textOnly) {
110+
Vditor.md2html(props.value, {
111+
mode: 'light',
112+
cdn,
113+
})
114+
.then((html) => {
115+
setText(convertToText(html));
116+
})
117+
.catch(() => setText(''));
118+
}
119+
}, [props.value, textOnly]);
120+
121+
const isOverflowTooltip = useCallback(() => {
122+
if (!elRef.current) return false;
123+
const contentWidth = getContentWidth(elRef.current);
124+
const offsetWidth = elRef.current?.offsetWidth;
125+
return contentWidth > offsetWidth;
126+
}, [elRef]);
127+
128+
if (props.ellipsis) {
129+
return (
130+
<Popover
131+
open={popoverVisible}
132+
getPopupContainer={() => document.getElementsByClassName('ant-drawer-content')?.[0] as HTMLElement}
133+
onOpenChange={(visible) => {
134+
setPopoverVisible(ellipsis && visible);
135+
}}
136+
overlayStyle={{ maxWidth: 400, maxHeight: 450, overflow: 'auto' }}
137+
content={<DisplayInner value={value} />}
138+
>
139+
<div
140+
ref={elRef}
141+
style={{
142+
overflow: 'hidden',
143+
overflowWrap: 'break-word',
144+
textOverflow: 'ellipsis',
145+
whiteSpace: 'nowrap',
146+
wordBreak: 'break-all',
147+
}}
148+
onMouseEnter={(e) => {
149+
const el = e.target as any;
150+
const isShowTooltips = isOverflowTooltip();
151+
if (isShowTooltips) {
152+
setEllipsis(el.scrollWidth >= el.clientWidth);
153+
}
154+
}}
155+
>
156+
{textOnly ? (
157+
text
158+
) : (
159+
<div
160+
className={css`
161+
.vditor-reset {
162+
white-space: nowrap;
163+
display: -webkit-box;
164+
-webkit-box-orient: vertical;
165+
-webkit-line-clamp: 1;
166+
overflow: hidden;
167+
text-overflow: ellipsis;
168+
word-break: break-word;
169+
}
170+
`}
171+
>
172+
<DisplayInner value={value} />
173+
</div>
174+
)}
175+
</div>
176+
</Popover>
177+
);
178+
}
179+
if (textOnly) {
180+
return text;
181+
}
182+
return <DisplayInner value={value} />;
183+
};

0 commit comments

Comments
 (0)