设计器(编辑器)这边内容比较杂,我们这次挑两个讲,一个是自定义出码,一个是新版本引擎中 array-setter 存在的问题
这期和之前的文章关联性不大,可以直接在阿里的低代码引擎初始化的目录下进行,如何搭建阿里低代码引擎平台可以参考我之前的文章
题外话:无法使用 antd 组件 / antd 组件样式不生效
我们在设计器当中开发时想引入 antd 的组件,会发现 antd 的组件无法使用,或者样式不生效。目前我的解决办法是将 antd 包升到 5.x 版本。我本地使用的是 5.7.1
自定义出码
引擎自带的出码是基于 React 的,组件是类式组件,也就是用的 class。很多场景都无法满足。
不过本文的重点还是专注于熟悉如何使用低代码引擎,不会对出码功能本身做太详细的展开,这次就带着做一个非常简单的 HTML 结构的出码功能,非组件式的
在页面上添加自己的按钮
第一步,我们需要在设计器页面当中添加自己的按钮,也就是入口
官网中的介绍如下
我的想法是添加一个按钮,按钮单独打开一个面板,将一部分功能集合全塞在这里,所以我选择新建一个 layout 目录,然后创建我们的按钮
import { IPublicModelPluginContext } from '@alilc/lowcode-types';
import CustomerPanel from './customerPanel'; // 这是一个自定义的组件
// 保存功能示例
const CustomerLayout = (ctx: IPublicModelPluginContext) => {
return {
async init() {
const { skeleton } = ctx;
skeleton.add({
name: 'CustomerLayout',
area: 'leftArea',
type: 'PanelDock',
props: {
align: 'right',
icon: "wenjian",
description: "自定义功能集合",
},
content: <CustomerPanel ctx={ctx} />,
panelProps: {
floatable: true, // 是否可浮动
height: 300,
hideTitleBar: false,
maxHeight: 800,
maxWidth: 1200,
title: "自定义功能集合",
width: 600,
},
});
},
};
}
CustomerLayout.pluginName = 'CustomerLayout';
CustomerLayout.meta = {
dependencies: ['EditorInitPlugin'],
};
export default CustomerLayout;
这是我的目录结构
并不一定非要取名 layout,这个是完全取决于自己的,引擎并没有约定的目录结构。customerPanel 是自定义的组件,大家自由发挥,这个和写普通组件是完全一样的。
找到 src 目录下的 index,导入我们的按钮并且注册
··· // 省略部分代码
import CustomerLayout from './layout';
··· // 省略部分代码
await plugins.register(CustomerLayout);
··· // 省略部分代码
刷新页面,我们就能够看到我们添加的按钮,在左侧的导航栏中
点击会弹出一个面板,而面板当中的内容,就取决于你刚刚自定义的组件内容,这里我添加了一个按钮,并设置点击时会再弹出一个面板,而我们自定义显示的出码代码就会在这个面板当中,这个部分完全自由发挥,请大家自行完成。
确认出码逻辑
这一步我们确认出码逻辑,先了解一下官方自带的出码逻辑
简单来讲,官方的出码是基于 schema 树的,这也是正确的做法,后续我们也会做相关的解析。这次暂时不做这么复杂的,我们做最简单的出码功能,不基于 schema,我们只把页面的 dom 树扒下来
我们随意拖几个组件进设计器,然后打开控制台,观察页面元素
可以看到,引擎把内容都放进了一个 iframe 中,在一个 id 为 app 的 dom 中
我们可以通过代码直接拿到 dom 树
const iframe = document.getElementsByClassName('lc-simulator-content-frame')[0]
// @ts-ignore
const domTree = iframe.contentWindow.document.getElementById('app');
const pageDom = domTree?.children[0];
const contentDom = (pageDom.cloneNode(true).children as Element[]);
const container = document.createElement('div');
container.append(...contentDom);
const domTreeHtml = container.innerHTML;
最后的 domTreeHtml 就是 dom 树了,要注意如果是容器组件的话,在容器组件中没有其他组件时,引擎会在容器组件中插入一个提示信息,就是下面这样
它的 dom 节点是这样的
因此我们直接拿 dom 树的话需要处理这个问题,直接遍历 dom 树清除就好了,可以参考下面的代码
const clearContainerDom = (elems: Element[]) => {
Array.from(elems)
for (const elem of Array.from(elems)) {
if (elem.classList.contains('lc-container-placeholder')) {
elem.parentElement?.removeChild(elem);
continue;
}
if (elem.children.length > 0) {
clearContainerDom((elem.children as unknown as Element[]));
}
}
}
然后在刚刚拿 dom 树的地方调用这个函数就行了
··· // 其他代码
clearContainerDom(contentDom);
const container = document.createElement('div');
container.append(...contentDom);
渲染 dom 代码
最后,展示我们的代码,这个网上的方案有很多,大家可以自行完成。我这边也从网上看到一个方案,大家可以参考
首先格式化 dom 代码的内容就不展示了,大家自行在网上查找解决方案
推荐大家使用 highlight.js 作为染色方案
import hljs from 'highlight.js/lib/common';
const target = hljs.highlightAuto(格式化后的dom代码, ['html']).value;
在 React 中,我们可以通过下面的方式将我们的 dom 放进元素当中
import React, { useRef, useState } from 'react';
···
const contentDom = useRef<HTMLPreElement>(null!);
···
const target = hljs.highlightAuto(text, ['html']).value; // 这是刚才的代码
contentDom.current.innerHTML = target;
···
<pre className='customer-code-pre'>
<code className='customer-code-block hljs language-html' ref={contentDom}></code>
</pre>
···
我们可以直接在样式中指定对应 class 的颜色
.hljs-tag,
.hljs-keyword,
.hljs-selector-tag,
.hljs-attr,
.hljs-literal,
.hljs-strong,
.hljs-name {
color: #f92672;
}
.hljs-string,
.hljs-bullet,
.hljs-subst,
.hljs-title,
.hljs-section,
.hljs-emphasis,
.hljs-type,
.hljs-built_in,
.hljs-builtin-name,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-addition,
.hljs-variable,
.hljs-template-tag,
.hljs-template-variable {
color: #a6e22e;
}
最后简单展示下效果
渲染代码和引擎基本没啥关系了,所以过的很快,如果有疑问可以留言
解决 array-setter 的问题
再补充一点目前引擎存在的问题,要讲这个需要结合物料库的内容(什么是物料库可以参考官方文档,或者我之前的文章)
问题描述
当我们设置一个组件的某个属性是数组类型时,我们可以在设计器当中添加这个数组的元素
比如下面这个组件
export interface AntdSelectProps {
/**
* 选项
* @componentName NewArraySetter
*/
options: {label: string, value: string}[]
}
const AntdSelect: React.FC<AntdSelectProps> = ({
options = []
}) => {
return (
<select>
<option value="">请选择</option>
{
options?.length > 0 &&
options.map((item, index) => {
return <option key={index} value={item.value}>{item.label}</option>
})
}
</select>
)
}
这是一个下拉框组件,它的下拉选项由传入的 options 属性决定
它在设计器当中是这样的
注意右侧的添加属性
我们点击后可以添加下拉选项
这是正常的效果,但是在最近几个版本当中,会出现问题
当我们添加一个选项之后再次点击,就会报错并且无法再次添加内容
我使用的引擎版本
"@alilc/lowcode-engine": "1.2.3",
"@alilc/lowcode-engine-ext": "1.0.6-beta.19",
这个问题查官方的 git 下面提了不少
解决办法
如果你本地跑起物料库的服务时,会发现在物料库自带的设计器当中并不存在这个问题,那么我的解决办法也非常简单,添加一个新的设置器替代现版本的 array-setter 设置器,而代码就来自物料库当中的 array-setter 代码
有点绕,我们看具体怎么做就行,回到我们设计器的目录下,新建一个 setters 目录,添加一个 new-array-setter.tsx
我们把物料库的服务跑起来,找到 array-setter 的代码位置:
webpack://AliLowCodeEngineExt/src/setter/array-setter/index.tsx
我们拷贝所有代码复制当我们刚刚的 new-array-setter.tsx,解决掉部分依赖和路径问题,最后修改成下面的代码
import * as React from 'react';
import { Component, Fragment } from 'react';
import { common } from '@alilc/lowcode-engine';
import { Button, Message } from '@alifd/next';
import { IPublicModelSettingField, IPublicTypeSetterType, IPublicTypeFieldConfig, IPublicTypeSetterConfig } from '@alilc/lowcode-types';
import CustomIcon from '@alilc/lowcode-engine-ext/es/components/custom-icon';
import Sortable from '@alilc/lowcode-engine-ext/es/setter/array-setter/sortable';
// import './style.less';
const { editorCabin, skeletonCabin } = common;
const { Title } = editorCabin;
const { createSettingFieldView, PopupContext } = skeletonCabin;
interface ArraySetterState {
items: IPublicModelSettingField[];
}
/**
* onItemChange 用于 ArraySetter 的单个 index 下的数据发生变化,
* 因此 target.path 的数据格式必定为 [propName1, propName2, arrayIndex, key?]。
*
* @param target
* @param value
*/
function onItemChange (target: IPublicModelSettingField, items: IPublicModelSettingField[], props: ArraySetterProps) {
const targetPath: Array<string | number> = target?.path;
if (!targetPath || targetPath.length < 2) {
console.warn(
`[ArraySetter] onItemChange 接收的 target.path <${
targetPath || 'undefined'
}> 格式非法需为 [propName, arrayIndex, key?]`,
);
return;
}
const { field, value: fieldValue } = props;
// const { items } = this.state;
const { path } = field;
if (path[0] !== targetPath[0]) {
console.warn(
`[ArraySetter] field.path[0] !== target.path[0] <${path[0]} !== ${targetPath[0]}>`,
);
return;
}
try {
const index = +targetPath[targetPath.length - 2];
if (typeof index === 'number' && !isNaN(index)) {
fieldValue[index] = items[index].getValue();
field?.extraProps?.setValue?.call(field, field, fieldValue);
}
} catch (e) {
console.warn('[ArraySetter] extraProps.setValue failed :', e);
}
};
interface ArraySetterProps {
value: any[];
field: IPublicModelSettingField;
itemSetter?: IPublicTypeSetterType;
columns?: IPublicTypeFieldConfig[];
multiValue?: boolean;
hideDescription?: boolean;
onChange?: Function;
extraProps: {renderFooter?: (options: ArraySetterProps & {onAdd: (val?: {}) => any}) => any}
}
export class ListSetter extends Component<ArraySetterProps, ArraySetterState> {
state: ArraySetterState = {
items: [],
};
private scrollToLast = false;
constructor(props: ArraySetterProps) {
super(props);
}
static getDerivedStateFromProps(props: ArraySetterProps, state: ArraySetterState) {
const items: IPublicModelSettingField[] = [];
const { value, field } = props;
const valueLength = value && Array.isArray(value) ? value.length : 0;
for (let i = 0; i < valueLength; i++) {
let item = state.items[i];
if (!item) {
item = field.createField({
name: i.toString(),
setter: props.itemSetter,
forceInline: 1,
type: 'field',
extraProps: {
defaultValue: value[i],
setValue: (target: IPublicModelSettingField) => {
onItemChange(target, items, props);
},
},
});
}
items.push(item);
}
return {
items,
};
}
onSort(sortedIds: Array<string | number>) {
const { onChange, value: oldValues } = this.props;
const { items } = this.state;
const values: any[] = [];
const newItems: IPublicModelSettingField[] = [];
sortedIds.map((id, index) => {
const item = items[+id];
item.setKey(index);
values[index] = oldValues[id as number];
newItems[index] = item;
return id;
});
this.setState({
items: newItems,
});
onChange?.(values);
}
onAdd(newValue?: {[key: string]: any}) {
const { itemSetter, field, onChange, value = [] } = this.props;
const values = value || [];
const initialValue = (itemSetter as any)?.initialValue;
const defaultValue = newValue ? newValue : (typeof initialValue === 'function' ? initialValue(field) : initialValue);
values.push(defaultValue);
this.scrollToLast = true;
onChange?.(values);
}
onRemove(removed: IPublicModelSettingField) {
const { onChange, value } = this.props;
const { items } = this.state;
const values = value || [];
let i = items.indexOf(removed);
items.splice(i, 1);
values.splice(i, 1);
const l = items.length;
while (i < l) {
items[i].setKey(i);
i++;
}
removed.remove();
const pureValues = values.map((item: any) => typeof(item) === 'object' ? Object.assign({}, item):item);
onChange?.(pureValues);
}
componentWillUnmount() {
this.state.items.forEach((field) => {
field.purge();
});
}
render() {
const { hideDescription, extraProps = {} } = this.props;
const { renderFooter } = extraProps;
let columns: any = null;
const { items } = this.state;
const { scrollToLast } = this;
this.scrollToLast = false;
if (this.props.columns) {
columns = this.props.columns.map((column) => (
<Title key={column.name} title={column.title || (column.name as string)} />
));
}
const lastIndex = items.length - 1;
const content =
items.length > 0 ? (
<div className="lc-setter-list-scroll-body">
<Sortable itemClassName="lc-setter-list-card" onSort={this.onSort.bind(this)}>
{items.map((field, index) => (
<ArrayItem
key={index}
scrollIntoView={scrollToLast && index === lastIndex}
field={field}
onRemove={this.onRemove.bind(this, field)}
/>
))}
</Sortable>
</div>
) : (
<div className="lc-setter-list-notice">
{this.props.multiValue ? (
<Message type="warning">当前选择了多个节点,且值不一致,修改会覆盖所有值</Message>
) : (
<Message type="notice" size="medium" shape="inline">
暂时还没有添加内容
</Message>
)}
</div>
);
return (
<div className="lc-setter-list lc-block-setter">
{!hideDescription && columns && items.length > 0 ? (
<div className="lc-setter-list-columns">{columns}</div>
) : null}
{content}
<div className="lc-setter-list-add">
{
!renderFooter ? (
<Button text type="primary" onClick={() => {
this.onAdd()
}}>
<span>添加一项 +</span>
</Button>
) : renderFooter({...this.props, onAdd: this.onAdd.bind(this),})
}
</div>
</div>
);
}
}
class ArrayItem extends Component<{
field: IPublicModelSettingField;
onRemove: () => void;
scrollIntoView: boolean;
}> {
private shell?: HTMLDivElement | null;
componentDidMount() {
if (this.props.scrollIntoView && this.shell) {
this.shell.parentElement!.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
render() {
const { onRemove, field } = this.props;
return (
<div
className="lc-listitem"
ref={(ref) => {
this.shell = ref;
}}
>
<div className="lc-listitem-body">{createSettingFieldView(field, field.parent)}</div>
<div className="lc-listitem-actions">
<Button size="small" ghost="light" onClick={onRemove} className="lc-listitem-action">
<CustomIcon type="icon-ic_delete" />
</Button>
<Button draggable size="small" ghost="light" className="lc-listitem-handler">
<CustomIcon type="icon-ic_drag" />
</Button>
</div>
</div>
);
}
}
class TableSetter extends ListSetter {
// todo:
// forceInline = 1
// has more actions
}
export default class ArraySetter extends Component<{
value: any[];
field: IPublicModelSettingField;
itemSetter?: IPublicTypeSetterType;
mode?: 'popup' | 'list';
forceInline?: boolean;
multiValue?: boolean;
}> {
static contextType = PopupContext;
private pipe: any;
render() {
const { mode, forceInline, ...props } = this.props;
const { field, itemSetter } = props;
let columns: IPublicTypeFieldConfig[] | undefined;
if ((itemSetter as IPublicTypeSetterConfig)?.componentName === 'ObjectSetter') {
const items: IPublicTypeFieldConfig[] = (itemSetter as any).props?.config?.items;
if (items && Array.isArray(items)) {
columns = items.filter(
(item) => item.isRequired || item.important || (item.setter as any)?.isRequired,
);
if (columns.length > 4) {
columns = columns.slice(0, 4);
}
}
}
if (mode === 'popup' || forceInline) {
const title = (
<Fragment>
编辑:
<Title title={field.title} />
</Fragment>
);
if (!this.pipe) {
let width = 360;
if (columns) {
if (columns.length === 3) {
width = 480;
} else if (columns.length > 3) {
width = 600;
}
}
this.pipe = this.context.create({ width });
}
this.pipe.send(<TableSetter key={field.id} {...props} columns={columns} />, title);
return (
<Button
type={forceInline ? 'normal' : 'primary'}
onClick={(e) => {
this.pipe.show((e as any).target, field.id);
}}
>
<CustomIcon type="icon-bianji" size="small" />
{forceInline ? title : '编辑数组'}
</Button>
);
} else {
return <ListSetter {...props} columns={columns?.slice(0, 4)} />;
}
}
}
然后就是注册这个设置器,我们找到 src\plugins\plugin-custom-setter-sample\index.tsx
import { IPublicModelPluginContext } from '@alilc/lowcode-types';
import NewArraySetter from 'src/setters/new-array-setter';
// 保存功能示例
const CustomSetterSamplePlugin = (ctx: IPublicModelPluginContext) => {
return {
async init() {
const { setters } = ctx;
setters.registerSetter('NewArraySetter', NewArraySetter);
},
};
}
CustomSetterSamplePlugin.pluginName = 'CustomSetterSamplePlugin';
export default CustomSetterSamplePlugin;
这里清除了没用的代码,如果拿不准不删除也是完全没问题的
最后就是怎样使用我们新建的设置器,回到物料库,找到对应组件的描述文件:lowcode/xxx/meta.ts
在此处指名要使用到的设置器即可
如果是跟着我物料库的文章做下来的,那更简单,直接在对应组件属性的注释当中声明即可
需要注意,之前的逻辑下必须要在 inject.config.js 配置了对应的组件才行,可以简单配置 group 或者 category 就能看到效果