一 . 需求拆解
二 . 对于画布大小的配置
由于需要打造一个大屏展示的低代码平台,那么大屏展示的方案就必不可少。这里采用的是固定宽高比,来根据视口大小自适应缩放或扩大的大屏展示方案来设计。也就是,在画布中间的配置区域,设置好大屏的尺寸,所有组件摆放位置根据计算的尺寸来进行转换。设置好的画布区域就是将来使用时候的全部屏幕。
tsx
//监听视口大小完成缩放的hooks
import { useCallback, useEffect, useState } from 'react';
import {
DEFAULT_DESIGN_HEIGHT,
DEFAULT_DESIGN_WIDTH,
} from 'src/costants/const';
const useWindowScale = (param: {
designWidth?: number; // 设计稿尺寸
designHeight?: number;
width?: number;
height?: number;
}) => {
const {
width,
height,
designWidth = DEFAULT_DESIGN_WIDTH,
designHeight = DEFAULT_DESIGN_HEIGHT,
} = param;
const [scale, setScale] = useState(1);
const handleResize = useCallback(() => {
const wrapperWidth = width ?? window.innerWidth;
const wrapperHeight = height ?? window.innerHeight;
//根据屏幕的变化适配的比例
const scale =
wrapperWidth / wrapperHeight < designWidth / designHeight
? wrapperWidth / designWidth
: wrapperHeight / designHeight;
setScale(scale);
}, [designHeight, designWidth, height, width]);
useEffect(() => {
window.addEventListener('resize', handleResize);
handleResize();
}, [handleResize, width]);
useEffect((): (() => void) | void => {
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
return {
scale,
};
};
export default useWindowScale;
import { Form } from 'antd';
import React from 'react';
import useWindowScale from 'src/hooks/useWindowScale';
import { useLowCodeContext } from './LargeScreenProvider';
export interface ScaleLayoutProps {
className?: any;
style?: React.CSSProperties;
children?: React.ReactNode;
width?: number;
height?: number;
}
// 使用useWindowScale,传入容器相应的宽高,把实际画布放在里面
const ScaleLayout = (props: ScaleLayoutProps) => {
const { children, style, width, height } = props;
const { designWidth, designHeight } = useLowCodeContext();
const { scale } = useWindowScale({
width,
height,
designWidth,
designHeight,
});
return (
<div
style={{
width: width ? `${width}px` : '100vw',
height: height ? `${height}px` : '100vh',
overflow: 'hidden',
background: 'rgba(5,22,4,1)',
position: 'relative',
...style,
}}
>
<div
style={{
width: `${designWidth}px`,
height: `${designHeight}px`,
transformOrigin: 'left top',
transform: `scale(${scale}) translate(-50%, -50%)`,
transition: 'transform .3s ease-in-out',
position: 'absolute',
top: '50%',
left: '50%',
background: 'white',
}}
>
{children}
</div>
</div>
);
};
export default ScaleLayout;
//实际的画布组件容器
import React, { useRef } from 'react';
import styles from './Content.module.less';
import ScaleLayout from '../components/ScaleLayout';
import { useSize } from 'ahooks';
const LowCodeContent: React.FC = () => {
const ref = useRef(null);
const size = useSize(ref);
return (
<div className={styles.layoutWrapper}>
<div ref={ref} className={styles.contentWrapper}>
<ScaleLayout width={size?.width} height={size?.height}>
<div ref={dropRef} className={styles.positionWrapper}>
内容区域
</div>
</ScaleLayout>
</div>
</div>
);
};
export default LowCodeContent;
三 . 对于拖拽方案的选择与实现
拖拽可以考虑使用监听鼠标位置,然后改变元素positions的方案,也可以使用H5自带的draggable属性来实现。
监听鼠标位置方案:
dom操作很频繁,需要一直设置元素的position属性,但是精准度较高。
H5的draggable方案:
可能遇到的问题:拖拽的时候会生成一个元素的缩略图在鼠标旁,但是由于我们的画布有scal属性的设置,缩略图并不会根据实际缩放来更新,照成视觉误差。查询到的解决方案:
尝试使用ahooks的
来实现拖拽功能并且拿到回调事件,通过form的setValue方法来保存定位信息
tsx
import React, { useRef } from 'react';
import styles from './LeftComponents.module.less';
import { useDrag } from 'ahooks';
const LeftComponents: React.FC = () => {
const dragRef = useRef(null);
useDrag('组件一', dragRef, {
onDragStart: (e) => {
console.log('onDragStart', e);
},
onDragEnd: (e) => {
console.log('onDragEnd', e);
},
});
return (
<>
<div className={styles.wrapper}>
<div ref={dragRef} className={styles.componentItem}>
一个拖拽的盒子
</div>
</div>
</>
);
};
export default LeftComponents;
const LowCodeContent: React.FC = () => {
const ref = useRef(null);
const size = useSize(ref);
const dropRef = useRef(null);
const form = Form.useFormInstance();
useDrop(dropRef, {
onDom: (content: string, e) => {
console.log('onDom', e);
const domEvent = e as React.DragEvent<Element> & {
offsetX: number;
offsetY: number;
};
form.setFieldValue(content, {
left: domEvent?.offsetX,
top: domEvent?.offsetY,
});
},
onDragEnter: (e) => {
console.log('onDragEnter', e);
},
onDragOver(e) {
// console.log('onDragOver', e);
},
onDragLeave(e) {
console.log('onDragLeave', e);
},
});
const renderComponent = () => {
const value = form.getFieldValue('组件一');
return (
<div
ref={dragRef}
draggable
style={{
width: `100px`,
height: `100px`,
position: 'absolute',
top: `${value.top}px`,
left: `${value.left}px`,
border: `1px solid`,
}}
></div>
);
};
return (
<div className={styles.layoutWrapper}>
<div ref={ref} className={styles.contentWrapper}>
<ScaleLayout width={size?.width} height={size?.height}>
<div ref={dropRef} className={styles.positionWrapper}>
<Form.Item shouldUpdate noStyle>
{() => {
return renderComponent();
}}
</Form.Item>
{}
</div>
</ScaleLayout>
</div>
</div>
);
};
export default LowCodeContent;
遇到的问题:
1. 拖拽倒是实现了,但是拿到的定位信息是鼠标指针的信息,并不是缩略图的左上角开始计算的,这时候会照成视觉误差。
解决方案:调用api event.dataTransfer?.setDragImage 设置缩略图的位置:
ts
useDrag('组件一', dragRef, {
onDragStart: (event) => {
// 创建并显示缩略图
const thumbnail = document.createElement('div');
thumbnail.innerHTML = 'BOX'; // 替换成您自己的缩略图内容
thumbnail.style.position = 'absolute';
thumbnail.style.backgroundColor = 'rgba(3, 3, 3, 0.3)';
thumbnail.style.width = '100px';
thumbnail.style.height = '50px';
thumbnail.style.position = 'absolute';
thumbnail.style.top = '-1000px';
document.body.appendChild(thumbnail);
// 设置缩略图为拖拽图像
event.dataTransfer?.setDragImage(thumbnail, 0, 0);
// 存储缩略图的引用,以便在 dragend 事件中进行清除
setMyThumbnail(thumbnail);
},
onDragEnd: (e) => {
console.log('onDragEnd=', e);
if (myThumbnail) {
document.body.removeChild(myThumbnail);
setMyThumbnail(null);
}
},
});
2.由于直接拿的 domEvent?.offsetX 作为画布内坐标的获取,刚开始确实没什么。但是offsetX属性得到的是距离最近定位元素的距离,这样就会导致,如果你拖拽到画布内其他元素的内部,获得的offsetX是距离那个元素的距离,但是在设置定位时候是基于画布定位的,会导致效果诡异。
typescript
useDrop(dropRef, {
onDom: (content: string, e) => {
console.log('onDom', e);
const domEvent = e as React.DragEvent<Element> & {
offsetX: number;
offsetY: number;
};
form.setFieldValue(content, {
left: domEvent?.offsetX,//(坑,要用pageX-当前容器坐标)
top: domEvent?.offsetY,
});
},
onDragEnter: (e) => {
console.log('onDragEnter', e);
},
onDragOver(e) {
// console.log('onDragOver', e);
},
onDragLeave(e) {
console.log('onDragLeave', e);
},
});
解决方案:选用(clientX减去容器当前的页面定位)* 缩放的数值(使用useLowCodeContext传递下去)算出来组件相对于容器的定位。
ts
const wrapperPosition = (
dropRef?.current as unknown as Element
)?.getBoundingClientRect();
const domEvent = e as React.DragEvent<Element> & {
offsetX: number;
offsetY: number;
};
const { left: wrapperLeft, top: wrapperTop } = wrapperPosition;
const { clientY, clientX } = domEvent;
const offsetX = (clientX - wrapperLeft) / scale;
const offsetY = (clientY - wrapperTop) / scale;
拖拽之后采用Form表单来保存数据
json
{
"designWidth": 1643,
"designHeight": 1243,
"CHART": [
{
"left": 489.46515679442507,
"top": 277.30006605407505,
"id": "0"
},
{
"left": 732.7665505226481,
"top": 314.51086744780326,
"id": "1"
},
]
}
实现方案为通过useDrop的onDom方法,拿到useDrag时传入的content值来作为form的key。
ts
//定义一些节点type的枚举
export enum NODE_TYPE {
ORDER = 'ORDER',
CHART = 'CHART',
}
//新增元素组件时触发拖拽事件,传给画布上读取节点类型用于新增form表单(LeftComponent组件用于新增元素)
const LeftComponents: React.FC = () => {
const dragRef = useRef(null);
const [myThumbnail, setMyThumbnail] = useState<HTMLDivElement | null>(null);
useDrag(NODE_TYPE.CHART, dragRef, {
onDragStart: (event) => {
// 创建并显示缩略图
const thumbnail = document.createElement('div');
thumbnail.innerHTML = NODE_TYPE.CHART; // 替换成您自己的缩略图内容
Object.assign(thumbnail.style, THUMBNAIL_STYLE);
document.body.appendChild(thumbnail);
// 设置缩略图为拖拽图像
event.dataTransfer?.setDragImage(thumbnail, 0, 0);
// 存储缩略图的引用,以便在 dragend 事件中进行清除
setMyThumbnail(thumbnail);
},
onDragEnd: (e) => {
// console.log('onDragEnd=', e);
if (myThumbnail) {
document.body.removeChild(myThumbnail);
setMyThumbnail(null);
}
},
});
return (
<>
<div draggable className={styles.wrapper}>
<div ref={dragRef} className={styles.componentItem}>
一个拖拽的盒子
</div>
</div>
</>
);
};
export default LeftComponents;
这时候又一个问题要注意就是,在拖拽已在画布上的元素时,应该触发修改表单元素而不是新增。所以当拖拽已有元素的时候,通过一个分割符加上在数据中的index拼接成新的content
tsx
//ContentComponentItem 组件
/** 分隔符 */
export const NAME_SEPARATOR = '@';
const ContentComponentItem: React.FC<ContentComponentItemProps> = ({
index,
}) => {
const dragRef = useRef(null);
const [myThumbnail, setMyThumbnail] = useState<HTMLDivElement | null>(null);
useDrag(`${NODE_TYPE.CHART}${NAME_SEPARATOR}${index}`, dragRef, {
onDragStart: (event) => {
// 创建并显示缩略图
const thumbnail = document.createElement('div');
thumbnail.innerHTML = NODE_TYPE.CHART; // 替换成您自己的缩略图内容
Object.assign(thumbnail.style, THUMBNAIL_STYLE);
document.body.appendChild(thumbnail);
// 设置缩略图为拖拽图像
event.dataTransfer?.setDragImage(thumbnail, 0, 0);
// 存储缩略图的引用,以便在 dragend 事件中进行清除
setMyThumbnail(thumbnail);
},
onDragEnd: (e) => {
console.log('onDragEnd=', e);
if (myThumbnail) {
document.body.removeChild(myThumbnail);
setMyThumbnail(null);
}
},
});
return (
<Form.Item shouldUpdate noStyle>
{(form) => {
const value = form.getFieldValue([NODE_TYPE.CHART, index]);
return (
<div
ref={dragRef}
style={{
width: `100px`,
height: `100px`,
position: 'absolute',
top: `${value?.top}px`,
left: `${value?.left}px`,
border: `1px solid`,
display: `${value ? 'block' : 'none'}`,
}}
>
{index}
</div>
);
}}
</Form.Item>
);
};
export default ContentComponentItem;
然后在画布组件接收放下事件时,根据这个分隔符解析出来是否为编辑,以及真正的content
tsx
const LowCodeContent: React.FC = () => {
const { scale } = useLowCodeContext();
const ref = useRef(null);
const size = useSize(ref);
const dropRef = useRef(null);
const form = Form.useFormInstance();
useDrop(dropRef, {
onDom: (content: string, e) => {
console.log('onDom', content, e);
const isEditContent = content.includes(NAME_SEPARATOR);
const [editContent, editContentIndex] = _.split(
content,
NAME_SEPARATOR
);
const wrapperPosition = (
dropRef?.current as unknown as Element
)?.getBoundingClientRect(); //容器的位置信息
const domEvent = e as React.DragEvent<Element> & {
offsetX: number;
offsetY: number;
};
const { left: wrapperLeft, top: wrapperTop } = wrapperPosition;
const { clientY, clientX } = domEvent;
const offsetX = (clientX - wrapperLeft) / scale;
const offsetY = (clientY - wrapperTop) / scale;
const contentValue =
form.getFieldValue(isEditContent ? editContent : content) ?? [];
const nameIndex = isEditContent
? editContentIndex
: contentValue?.length ?? 0; //如果是修改元素,传入相应的name,如果是新增 name自增
form.setFieldValue(
[isEditContent ? editContent : content, nameIndex],
{
left: offsetX,
top: offsetY,
id: nameIndex,
}
);
},
});
return (
<div className={styles.layoutWrapper}>
<div ref={ref} className={styles.contentWrapper}>
<ScaleLayout width={size?.width} height={size?.height}>
<div ref={dropRef} className={styles.positionWrapper}>
内容区域
<Form.Item noStyle shouldUpdate>
{(form) => {
const contentValue =
form.getFieldValue(NODE_TYPE.CHART) ?? [];
console.log(contentValue);
return contentValue?.map(
(item: any, index: number) => {
return (
<ContentComponentItem
key={item?.id}
index={index}
/>
);
}
);
}}
</Form.Item>
</div>
</ScaleLayout>
</div>
</div>
);
};
export default LowCodeContent;
自此,一个使用 ahooks的useDrop & useDrag 以及Form表单 实现的拖拽元素到指定可缩放画布的功能就完成了。