feat: markdown & iframe html support handlebars as rendering engin (#4946)

* feat: markdown support setting rendering engin

* refactor: markdown

* refactor: markdown

* refactor: markdown

* refactor: markdown

* fix: bug

* fix: bug

* fix: bug

* fix: markdown style

* fix: markdown style

* fix: bug
This commit is contained in:
Katherine 2024-08-02 11:58:11 +08:00 committed by GitHub
parent e358ce378e
commit 0f4dd0b3ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 205 additions and 40 deletions

View File

@ -2,9 +2,7 @@
"version": "1.3.0-alpha",
"npmClient": "yarn",
"useWorkspaces": true,
"npmClientArgs": [
"--ignore-engines"
],
"npmClientArgs": ["--ignore-engines"],
"command": {
"version": {
"forcePublish": true,

View File

@ -966,5 +966,6 @@
"Search": "搜索",
"Clear default value": "清除默认值",
"Open in new window": "新窗口打开",
"Sorry, the page you visited does not exist.": "抱歉,你访问的页面不存在。"
"Sorry, the page you visited does not exist.": "抱歉,你访问的页面不存在。",
"Set Template Engine": "设置模板引擎"
}

View File

@ -29,6 +29,7 @@ export const MarkdownBlockInitializer = () => {
'x-decorator': 'CardItem',
'x-decorator-props': {
name: 'markdown',
engine: 'handlebars',
},
'x-component': 'Markdown.Void',
'x-editable': false,

View File

@ -11,7 +11,7 @@ import { useField } from '@formily/react';
import { useTranslation } from 'react-i18next';
import { SchemaSettings } from '../../../../application/schema-settings/SchemaSettings';
import { SchemaSettingsBlockHeightItem } from '../../../../schema-settings/SchemaSettingsBlockHeightItem';
import { SchemaSettingsRenderEngine } from '../../../../schema-settings/SchemaSettingsRenderEngine';
export const markdownBlockSettings = new SchemaSettings({
name: 'blockSettings:markdown',
items: [
@ -30,6 +30,10 @@ export const markdownBlockSettings = new SchemaSettings({
};
},
},
{
name: 'setBlockTemplate',
Component: SchemaSettingsRenderEngine,
},
{
name: 'setTheBlockHeight',
Component: SchemaSettingsBlockHeightItem,

View File

@ -15,6 +15,7 @@ import {
SchemaSettingsDivider,
SchemaSettingsItem,
SchemaSettingsRemove,
SchemaSettingsRenderEngine,
} from '../../../schema-settings';
import { SchemaSettingsBlockHeightItem } from '../../../schema-settings/SchemaSettingsBlockHeightItem';
@ -29,6 +30,7 @@ export const MarkdownVoidDesigner = () => {
field.editable = true;
}}
/>
<SchemaSettingsRenderEngine />
<SchemaSettingsBlockHeightItem />
<SchemaSettingsDivider />
<SchemaSettingsRemove

View File

@ -13,20 +13,22 @@ import type { TextAreaRef } from 'antd/es/input/TextArea';
import cls from 'classnames';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { useGlobalTheme } from '../../../global-theme';
import { useDesignable } from '../../hooks/useDesignable';
import { MarkdownVoidDesigner } from './Markdown.Void.Designer';
import { useStyles } from './style';
import { parseMarkdown } from './util';
import { TextAreaProps } from 'antd/es/input';
import { useBlockHeight } from '../../hooks/useBlockSize';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { useCollectionRecord } from '../../../data-source';
import { useVariableOptions } from '../../../schema-settings/VariableInput/hooks/useVariableOptions';
import { VariableSelect } from '../variable/VariableSelect';
import { replaceVariableValue } from '../../../block-provider/hooks';
import { useLocalVariables, useVariables } from '../../../variables';
import { registerQrcodeWebComponent } from './qrcode-webcom';
import { getRenderContent } from '../../common/utils/uitls';
import { parseMarkdown } from './util';
import { useCompile } from '../../';
export interface MarkdownEditorProps extends Omit<TextAreaProps, 'onSubmit'> {
scope: any[];
defaultValue?: string;
@ -82,11 +84,19 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
}}
style={{ paddingBottom: '40px' }}
/>
<>
<span style={{ marginLeft: '.25em' }} className={'ant-formily-item-extra'}>
{t('Syntax references')}:
</span>
<a href="https://handlebarsjs.com/guide/" target="_blank" rel="noreferrer">
Handlebars.js
</a>
</>
<div style={{ position: 'absolute', top: 21, right: 1 }}>
<VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} />
</div>
<Space style={{ position: 'absolute', bottom: 5, right: 5 }}>
<Space style={{ position: 'absolute', bottom: 25, right: 5 }}>
<Button
onClick={(e) => {
props.onCancel?.(e);
@ -129,17 +139,27 @@ export const MarkdownVoid: any = withDynamicSchemaProps(
const [html, setHtml] = useState('');
const variables = useVariables();
const localVariables = useLocalVariables();
const { engine } = schema?.['x-decorator-props'] || {};
const [loading, setLoading] = useState(false);
const compile = useCompile();
useEffect(() => {
setLoading(true);
const cvtContentToHTML = async () => {
const replacedContent = await replaceVariableValue(content, variables, localVariables);
const html = await parseMarkdown(replacedContent);
setHtml(html);
const replacedContent = await getRenderContent(
engine,
content,
compile(variables),
compile(localVariables),
parseMarkdown,
);
const sanitizedHtml = DOMPurify.sanitize(replacedContent);
setHtml(sanitizedHtml);
setLoading(false);
};
cvtContentToHTML();
}, [content, variables, localVariables]);
}, [content, variables, localVariables, engine]);
const height = useMarkdownHeight();
const scope = useVariableOptions({
collectionField: { uiSchema: schema },

View File

@ -22,10 +22,12 @@ export function useParseMarkdown(text: string) {
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
parseMarkdown(text).then((r) => {
setHtml(r);
setLoading(false);
});
parseMarkdown(text)
.then((r) => {
setHtml(r);
setLoading(false);
})
.catch((error) => console.log(error));
}, [text]);
return { html, loading };
}

View File

@ -10,3 +10,4 @@
export * from './dnd-context';
export * from './sortable-item';
export * from './show-form-data';
export { getRenderContent } from './utils/uitls';

View File

@ -8,10 +8,12 @@
*/
import _, { every, findIndex, some } from 'lodash';
import Handlebars from 'handlebars';
import { VariableOption, VariablesContextType } from '../../../variables/types';
import { isVariable } from '../../../variables/utils/isVariable';
import { transformVariableValue } from '../../../variables/utils/transformVariableValue';
import { getJsonLogic } from '../../common/utils/logic';
import { replaceVariableValue } from '../../../block-provider/hooks';
type VariablesCtx = {
/** 当前登录的用户 */
@ -138,3 +140,32 @@ export function targetFieldToVariableString(targetField: string[]) {
// Action 中的联动规则虽然没有 form 上下文但是在这里也使用的是 `$nForm` 变量,这样实现更简单
return `{{ $nForm.${targetField.join('.')} }}`;
}
const getVariablesData = (localVariables) => {
const data = {};
localVariables.map((v) => {
data[v.name] = v.ctx;
});
return data;
};
export async function getRenderContent(templateEngine, content, variables, localVariables, defaultParse) {
if (content && templateEngine === 'handlebars') {
const renderedContent = Handlebars.compile(content);
// 处理渲染后的内容
try {
const data = getVariablesData(localVariables);
return renderedContent({ ...variables.ctxRef.current, ...data });
} catch (error) {
console.log(error);
return content;
}
} else {
try {
const html = await replaceVariableValue(content, variables, localVariables);
return await defaultParse(html);
} catch (error) {
return content;
}
}
}

View File

@ -0,0 +1,53 @@
/**
* 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 { Field } from '@formily/core';
import { useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTableBlockContext } from '../block-provider';
import { useDesignable } from '../schema-component/hooks/useDesignable';
import { SchemaSettingsSelectItem } from './SchemaSettings';
export function SchemaSettingsRenderEngine() {
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const { dn } = useDesignable();
const { service } = useTableBlockContext();
const options = [
{
value: 'string',
label: t('String template'),
},
{
value: 'handlebars',
label: t('Handlebars'),
},
];
return (
<SchemaSettingsSelectItem
key="render-template"
title={t('Set Template Engine')}
options={options}
value={field.decoratorProps.engine || 'string'}
onChange={(engine) => {
fieldSchema['x-decorator-props'].engine = engine;
field.decoratorProps.engine = engine;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-decorator-props': fieldSchema['x-decorator-props'],
},
});
dn.refresh();
}}
/>
);
}

View File

@ -22,6 +22,7 @@ export * from './SchemaSettingsTemplate';
export * from './SchemaSettingsBlockHeightItem';
export * from './setDefaultSortingRulesSchemaSettingsItem';
export * from './setTheDataScopeSchemaSettingsItem';
export * from './SchemaSettingsRenderEngine';
export * from './hooks/useGetAriaLabelOfDesigner';
export * from './hooks/useIsAllowToSetDefaultValue';
export { default as useParseDataScopeFilter } from './hooks/useParseDataScopeFilter';

View File

@ -29,7 +29,7 @@ export const IframeDesigner = () => {
const { t } = useTranslation();
const { dn } = useDesignable();
const api = useAPIClient();
const { mode, url, htmlId, height = '60vh' } = fieldSchema['x-component-props'] || {};
const { mode, url, htmlId, height = '60vh', engine } = fieldSchema['x-component-props'] || {};
const saveHtml = async (html: string) => {
const options = {
@ -46,11 +46,13 @@ export const IframeDesigner = () => {
}
};
const submitHandler = async ({ mode, url, html, height }) => {
const submitHandler = async ({ mode, url, html, height, engine }) => {
const componentProps = fieldSchema['x-component-props'] || {};
componentProps['mode'] = mode;
componentProps['height'] = height;
componentProps['url'] = url;
componentProps['engine'] = engine || 'string';
if (mode === 'html') {
const data = await saveHtml(html);
componentProps['htmlId'] = data.id;
@ -125,6 +127,23 @@ export const IframeDesigner = () => {
},
},
},
engine: {
title: '{{t("Template engine")}}',
'x-component': 'Radio.Group',
'x-decorator': 'FormItem',
enum: [
{ value: 'string', label: t('String template') },
{ value: 'handlebars', label: t('Handlebars') },
],
'x-reactions': {
dependencies: ['mode'],
fulfill: {
state: {
hidden: '{{$deps[0] === "url"}}',
},
},
},
},
html: {
title: t('html'),
type: 'string',

View File

@ -9,12 +9,13 @@
import { observer, useField } from '@formily/react';
import {
replaceVariableValue,
useCompile,
useBlockHeight,
useLocalVariables,
useParseURLAndParams,
useRequest,
useVariables,
getRenderContent,
} from '@nocobase/client';
import { Card, Spin } from 'antd';
import React, { useEffect, useState } from 'react';
@ -32,13 +33,14 @@ function isNumeric(str: string | undefined) {
}
export const Iframe: any = observer(
(props: IIframe & { html?: string; htmlId?: number; mode: string; params?: any }) => {
const { url, htmlId, mode = 'url', height, html, params, ...others } = props;
(props: IIframe & { html?: string; htmlId?: number; mode: string; params?: any; engine?: string }) => {
const { url, htmlId, mode = 'url', height, html, params, engine, ...others } = props;
const field = useField();
const { t } = useTranslation();
const targetHeight = useBlockHeight() || height;
const variables = useVariables();
const localVariables = useLocalVariables();
const compile = useCompile();
const { loading, data: htmlContent } = useRequest<string>(
{
url: `iframeHtml:getHtml/${htmlId}`,
@ -50,13 +52,21 @@ export const Iframe: any = observer(
);
const { parseURLAndParams } = useParseURLAndParams();
const [src, setSrc] = useState(null);
useEffect(() => {
const generateSrc = async () => {
if (mode === 'html') {
const targetHtmlContent = await replaceVariableValue(htmlContent, variables, localVariables);
const targetHtmlContent = await getRenderContent(
engine,
htmlContent,
compile(variables),
compile(localVariables),
(data) => {
return data;
},
);
const encodedHtml = encodeURIComponent(targetHtmlContent);
const dataUrl = 'data:text/html;charset=utf-8,' + encodedHtml;
setSrc(dataUrl);
} else {
try {

View File

@ -50,7 +50,7 @@ const commonOptions: any = {
const { t } = useTranslation();
const { dn } = useDesignable();
const api = useAPIClient();
const { mode, url, params, htmlId, height = '60vh' } = fieldSchema['x-component-props'] || {};
const { mode, url, params, htmlId, height = '60vh', engine } = fieldSchema['x-component-props'] || {};
const saveHtml = async (html: string) => {
const options = {
values: { html },
@ -66,10 +66,11 @@ const commonOptions: any = {
}
};
const { urlSchema, paramsSchema } = useURLAndHTMLSchema();
const submitHandler = async ({ mode, url, html, height, params }) => {
const submitHandler = async ({ mode, url, html, height, params, engine }) => {
const componentProps = fieldSchema['x-component-props'] || {};
componentProps['mode'] = mode;
componentProps['height'] = height;
componentProps['engine'] = engine || 'string';
componentProps['params'] = params;
componentProps['url'] = url;
if (mode === 'html') {
@ -94,6 +95,7 @@ const commonOptions: any = {
mode,
url,
height,
engine,
params,
};
if (htmlId) {
@ -123,15 +125,42 @@ const commonOptions: any = {
required: true,
},
params: paramsSchema,
html: {
title: t('html'),
type: 'string',
engine: {
title: '{{t("Template engine")}}',
'x-component': 'Radio.Group',
'x-decorator': 'FormItem',
'x-component': getVariableComponentWithScope(Variable.RawTextArea),
'x-component-props': {
rows: 10,
},
required: true,
enum: [
{ value: 'string', label: t('String template') },
{ value: 'handlebars', label: t('Handlebars') },
],
'x-reactions': {
dependencies: ['mode'],
fulfill: {
state: {
hidden: '{{$deps[0] === "url"}}',
},
},
},
},
html: {
title: t('html'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': getVariableComponentWithScope(Variable.RawTextArea),
'x-component-props': {
rows: 10,
},
required: true,
description: (
<>
<span style={{ marginLeft: '.25em' }} className={'ant-formily-item-extra'}>
{t('Syntax references')}:
</span>
<a href="https://handlebarsjs.com/guide/" target="_blank" rel="noreferrer">
Handlebars.js
</a>
</>
),
'x-reactions': {
dependencies: ['mode'],
fulfill: {
@ -141,13 +170,6 @@ const commonOptions: any = {
},
},
},
// height: {
// title: t('Height'),
// type: 'string',
// 'x-decorator': 'FormItem',
// 'x-component': 'Input',
// required: true,
// },
},
} as ISchema,
onSubmit: submitHandler,