扯皮
最近开始着手做毕设了,靠着大学四年积累的人脉直接跟导师说想要做一个重在前端的毕设,不是前端的话我就开混了😁😁😁
经过自己软磨硬泡最终导师还是妥协了,至于项目主题我早就想好了,就是低代码!🧐
因为之前了解过低代码但是一直没有上手实践,所以也是一边写一边踩坑了。这不,刚开始就遇到了一些问题…😅
正文
正如标题所说,我们这次将要二次封装一个标尺组件,技术栈为:React + TS
关于 React 的标尺组件的选择,经过我在 github 的调研发现了这个项目👇:
ruler/packages/react-ruler at master · daybrush/ruler (github.com)
A Ruler component that can draw grids and scroll infinitely. (daybrush.com)
虽然 Star 数并不多(281),但是简单看了下文档已经完全满足我的需求了,所以就决定用它了!😜
为什么要二次封装
我们都知道一个低代码平台通常都是这样的布局:
标尺组件就应用在 Canvas 画布区,之所以需要标尺就是给用户提示更方便的控制组件尺寸🧐
而根据我对一些开源的低代码项目调研,通常画布区使用标尺的布局分为两种:
注意两种布局针对于画布区零刻度线的位置是不一样的,而且第二种需要多一层 layout 来包裹整个画布区
针对于这两种布局方式的产品我都有简单使用过,从使用体验角度来讲我更认可第二种实现方式😜
因为它不仅实现了基础的缩放功能,还可以在整个容器内部随意拖动画布,像我的小尺寸笔记本即使整个 Canvas 区比较小也能获得很好的体验
当然具体使用哪种方式需要看低代码的实际业务,比如我这次的题目做的是数据可视化组件的低代码,因此对物料摆放是有一定要求的
所以我们这次封装的标尺组件按第二种方式来,封装之前肯定还是需要确定一下需求:
- 画布区域可以随意滚动和拖动
- 画布区域可以实现缩放功能,比如快捷键 CTRL + 滚轮或者一个 Slider 控制
- 自适应计算画布区大小并设置位置
而这三个需求满足的同时还要注意画布区与标尺零刻度线的对齐,这就是整个组件的最终目标😇
使用 Ruler 组件搭建基本结构
我们先把一个低代码项目的基本结构搭建出来,为了方便起见直接启动 vite + react-ts 模板,在 App 组件中搭建,稍微写一些布局样式:
import "./app.css";
const App = () => {
return (
<div className="container">
<div className="content-box">
<div className="content-left"></div>
<div className="content-center">
{/* 内容区 */}
</div>
<div className="content-right"></div>
</div>
</div>
);
};
export default App;
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.content-box {
display: flex;
width: 95vw;
height: 95vh;
border: 1px solid red;
}
.content-center {
flex: 1;
background-color: #eee;
border: 1px solid blue;
}
.content-left,
.content-right {
width: 200px;
height: 100%;
flex-shrink: 0;
border: 1px solid #000;
}
我们要封装的标尺组件就是要应用在中间的内容区😇
安装 react-ruler 组件:
pnpm add @scena/react-ruler
创建 FsRulerContainer 组件,我们安装的 ruler 组件是单独一个标尺,但是可以通过 type
属性设置为水平或垂直方向,所以按照第二种方式的布局我们需要使用两个 ruler 组件应用在不同方向
将整个中间区域划分为两个盒子,左边盒子承载垂直方向的标尺组件,右边的盒子承载水平标尺组件以及我们的画布内容
关于这里 react-ruler 组件不重要的属性不再详细介绍,有用到属性会进行解释,至于其他的可以访问上面的链接查看文档,虽然是英文但根据给的 example 相信大伙都能看明白
import Ruler from "@scena/react-ruler";
import "./ruler.css";
const FsRulerContainer = () => {
return (
<div className="fs-ruler-container">
<div className="left">
<div className="px-box">px</div>
<Ruler
type="vertical"
lineColor={"#aaa"}
textColor={"#000"}
backgroundColor={"#fff"}
negativeRuler={true}
segment={2}
textOffset={[10, 0]}
/>
</div>
<div className="right">
<div style={{ height: "20px" }}>
<Ruler
type="horizontal"
lineColor={"#aaa"}
textColor={"#000"}
backgroundColor={"#fff"}
negativeRuler={true}
segment={2}
textOffset={[0, 10]}
/>
</div>
</div>
</div>
);
};
export default FsRulerContainer;
再加点简单的 css:
.fs-ruler-container {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
}
.left {
width: 20px;
height: 100%;
}
.px-box {
font-size: 12px;
text-align: center;
height: 20px;
background: #fff;
}
.right {
flex: 1;
}
就这样我们就能够得到一个标尺组件啦~🥰简单吧:
还没完,我们按照上面提到的第二种方式真正的 Canvas 是靠在中间的,稍微再改造一下中间的内容区域
它由三部分组成:container、layout、canvas,这三部分的关系画张图来解释:
container 将作为内容区的容器,大小就是整个内容区,将它设置为 overflow: auto
layout 是 canvas 的承载容器,它的大小会设置为 canvas 的二倍或者更大,为了使 container 出现滚动条,而我们后续的拖拽需求本质上就是通过改变 container 的 scrollTop、scrollLeft 实现
canvas 画布将定位在 layout 当中,居中或者靠左都可以,主要是得让它至少出现在 container 视野中
我们先写死一个 800 * 600 的画布尺寸,那么 layout 的大小就设置为 1600 * 1200,为了保证画布尽量在视图中展示,我们再设置一个 marignLeft 负值让画布在 layout 中水平居中
一般情况下我们会设置一个插槽,让父组件决定画布的具体内容,所以直接拿来 children 渲染即可:
import { useState } from "react";
import Ruler from "@scena/react-ruler";
import "./ruler.css";
interface IFsRulerContainer {
children: JSX.Element;
}
const FsRulerContainer = (props: IFsRulerContainer) => {
const { children } = props;
const [width] = useState(800);
const [height] = useState(600);
return (
<div className="fs-ruler-container">
<div className="left">
<div className="px-box">px</div>
{/* ruler组件 */}
</div>
<div className="right">
<div style={{ height: "20px" }}>
{/* ruler组件 */}
</div>
<div className="content-container">
<div className="content-layout" style={{ width: `${width * 2}px`, height: `${height * 2}px` }}>
<div className="content-canvas" style={{ width: `${width}px`, height: `${height}px, marginLeft: `-${Math.floor(width / 2)}px`` }}>
{children}
</div>
</div>
</div>
</div>
</div>
);
};
export default FsRulerContainer;
css 还是老样子
.content-container {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
}
.content-layout {
position: absolute;
top: 0;
left: 0;
}
.content-canvas {
position: absolute;
top: 25%;
left: 50%;
transform-origin: left top;
background-color: #fff;
border-radius: 10px;
}
为了方便起见我们直接全局把滚动条隐藏了,太碍事了留着它后面计算位置还需要考虑它的大小😒:
::-webkit-scrollbar {
width: 0;
}
现在再来看看我们的视图:
可以滚动啦🤪,不过我们想要实现的效果就是滚动的同时它的刻度也要与画布保持对齐,下面就着手开始实现它!
注意为了保证要讲内容的核心代码展示更加清晰,后续会省略部分细节,比如一些属性、DOM 结构等
实现标尺对齐
这里需要用到 react-ruler 提供的一个属性:scrollPos
,顾名思义,它就是设置标尺滚动位置的,我们实现对齐功能最主要的就是靠它,一般标尺组件都会提供类似的属性
默认情况下 scrollPos 为 0,如上面的动图所示标尺零刻度线都在左上角位置,但是我们的画布当前的位置是[水平: 200,垂直: 300],我们把水平标尺的 scrollPos 设为 -200,垂直标尺的 scrollPos 设为 -300 (标尺左移 scrollPos > 0,右移 < 0) 再来看看:
<div className="left">
<div className="px-box">px</div>
<Ruler
type="vertical"
negativeRuler={true}
scrollPos={-300}
/>
</div>
<div className="right">
<div style={{ height: "20px" }}>
<Ruler
type="horizontal"
negativeRuler={true}
scrollPos={-200}
/>
</div>
</div>
</div>
可以看到这时候就对齐了,不过不要忘记了我们的容器是可以滚动的,而且初始状态画布位置可能会根据其大小而改变,所以现在就有两个任务了:
- 初始状态需要 DOM 计算位置来设置标尺的
scrollPos
- 垂直方向上进行滚动时对应的标尺需要动态设置
scrollPos
先来看初始状态我们要设置标尺滚动的位置,实际上其实就是 container 与 canvas 之间的位置关系,上图:
我们只需要求出来这两段距离不就完事了,所以获取 DOM、计算距离、设置 scrollPos
、结束,直接上核心代码:
const [posX, setPosX] = useState(0);
const [posY, setPosY] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const { disX, disY } = computedDis();
setPosX(disX);
setPosY(disY);
}, []);
const computedDis = () => {
const containerRect = containerRef.current!.getBoundingClientRect();
const canvasRect = canvasRef.current!.getBoundingClientRect();
const disX = Math.floor(containerRect.left) - Math.floor(canvasRect.left);
const disY = Math.floor(containerRect.top) - Math.floor(canvasRect.top);
return { disX, disY };
};
JSX 中绑定 ref 以及 state 我就省略了,这里面唯一要注意的点就是之前提到的 scrollPos
正负值代表的含义,在计算时考虑到这一点即可
现在我们随便刷新页面,初始状态下标尺都会对齐了:
下面考虑滚动问题,我们直接给 container 绑定滚动事件,利用之前实现计算位置的函数,每当进行滚动时都进行计算设置标尺位置,不过我们同时也要计算水平方向的位置,这与后面实现拖拽效果有关:
const handleScroll = () => {
const { disX, disY } = computedDis();
setPosX(Math.floor(disX));
setPosY(Math.floor(disY));
};
现在可以看到无论怎么滚动标尺零刻度都始终与画布对齐了:
到此我们已经实现了标尺对齐的基本操作,不过垂直方向上滚动对齐了,水平方向呢?🤔
其实水平方向上的移动并不是采取滚动条的形式而是拖拽,下一部分就来实现这个拖拽效果
实现画布拖拽对齐
首先我们需要明白到底拖拽的对象是谁,可能你觉得是画布,通过改变它的定位实现拖拽效果🤔,其实并不是,实际上我们是以 layout 与 canvas 为整体,不断改变 container 的 scrollTop、scrollLeft 达到视觉上像是拖拽画布的效果,如下面的动图所示,我们拖拽的效果是这样的:
明白了这一点就好办了,我们以点击 canvas 为拖拽的开始,所以用到 mousedown、mousemove、mouseup 这三个鼠标事件:
let startX = 0,
startY = 0;
useEffect(() => {
dragCanvas();
// ...
}, []);
const handleScroll = () => {
const { disX, disY } = computedDis();
setPosX(Math.floor(disX));
setPosY(Math.floor(disY));
};
const dragCanvas = () => {
canvasRef.current?.addEventListener("mousedown", (e: any) => {
e.preventDefault();
e.stopPropagation();
startX = e.pageX + containerRef.current!.scrollLeft;
startY = e.pageY + containerRef.current!.scrollTop;
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
});
};
const handleMouseMove = (e: any) => {
containerRef.current!.scrollLeft = startX - e.pageX;
containerRef.current!.scrollTop = startY - e.pageY;
};
const handleMouseUp = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
很简单的计算,无非就是计算鼠标移动的距离,设置到 scrollLeft、scrollTop 上即可,而这样的拖拽也会导致我们之前实现的 scroll 事件执行,因此标尺位置也跟着重置对齐,达到我们想要的目的:
画布缩放、放大功能
有时滚动和拖拽并不能满足我们的使用需求,比如画布尺寸过大导致我们无法总览画布的全貌,画布过小导致我们使用时一些细节把控不到位😑
因此就需要实现画布缩放和放大的功能,最常见的就是 CTRL + 滚轮,会发现我们的网页会跟着缩小放大,这就是我们要实现的效果🧐
像标尺组件一般也会提供对应的属性来设置缩放大小,react-ruler 为我们提供了 zoom
属性,默认值为 1,完全可以把它当作 transform 中的 scale 使用,简单设置几个值给大伙看看:
zoom: 0.5
zoom: 1
zoom: 2
所以我们在组件中增加一个 scale
变量表示当前缩放大小,开始绑定鼠标滚动事件,不过注意我们要防止实现的缩放功能与网页的缩放冲突,所以针对于鼠标滚动事件需要阻止其默认行为
但是鼠标滚动事件在 react 中绑定默认为 passive: true,这让我们无法直接调用 e.preventDefault(),所以我们只能通过手动 addEventListener 进行添加了:
不过最好还是限制一下缩放和放大的比例吧,过大还好说,过小亲测 canvas 绘制会卡死,而且刻度已经没法看了😶
const [scale, setScale] = useState(1);
useEffect(() => {
// ...
containerRef.current!.addEventListener("wheel", handleWheel, { passive: false });
}, []);
const handleWheel = (e: any) => {
// 判断 ctrl 是否按下
if (e.ctrlKey) {
e.preventDefault();
// 滚动方向判断
if (e.wheelDelta > 0) {
setScale((pre) => {
if (pre >= 2) return pre;
return pre + 0.1;
});
} else if (e.wheelDelta < 0) {
setScale((pre) => {
if (scale <= 0.5) return pre;
return pre - 0.1;
});
}
}
};
还有一点需要注意,现在我们增加了缩放比例这一要素,那之前的计算标尺滚动位置也要考虑到 scale 带来的影响,所以我们监听 scale 的变化,当进行缩放或放大时重新计算标尺位置:
useEffect(() => {
canvasRef.current && handleScroll();
}, [scale]);
const handleScroll = () => {
const { disX, disY } = computedDis();
// 考虑 scale 计算
setPosX(Math.floor(disX / scale));
setPosY(Math.floor(disY / scale));
};
最后不要忘了给标尺绑定对应的 zoom
属性,同时给画布也绑定 scale
样式哦🤪🤪🤪
都完成了以后来看现在的效果吧:
可以看到现在拖拽、缩放都实现了,而且无论怎么操作标尺的刻度始终能够与画布对齐!🥰🥰🥰
自适应布局
为什么需要自适应布局?这是因为使用我们平台的设备屏幕大小不同,比如我自己有 15.6 寸笔记本,也有 24 寸的显示器,但是我们使用低代码平台时画布的大小肯定是统一的,这就会导致大点的屏幕看到画布完整一些,而小屏幕可能都看不到画布在哪了
现在我们的需求是想要在初始状态时自动计算当前容器大小,然后根据这个大小来调整我们画布的缩放比例以及布局,相当于让它自适应当前容器,然后展示到我们的屏幕中间,这样即使用户屏幕大小不同,我们也能看到一个完整的画布😎
其实相当于一个画布大小位置自动调整的功能,不止是初始化状态,可能我们更改当前页面的布局,都需要重新计算 container 再计算画布位置,这样给用户的体验会更好
总结下来我们两个任务:
- 根据当前 container 计算一个缩放比例
- 将画布移动至我们的 container 中央
关于这里的缩放比例需要好好研究怎么计算出来🤔,其实这与 container、canvas 的宽高比有关,我们通过比较这两个值就有结果了
比如 canvas 比例为 16:9(1920 * 1080),container 比例为 20:13(1000 * 650),这时候 canvas 比例更大,说明画布其实是更宽的,那缩放比例直接按照 container 与 canvas 的宽度比即可
反之说明画布更高,那缩放比例直接按照 container 与 canvas 的高度比
不过需要注意可能会出现 container 尺寸比整个 canvas 尺寸大的情况,那计算的缩放比例肯定是大于 1 的,那其实就没必要放大了,直接保证原来的比例就行
其次就是画布移动,本质上是 container 移动 scrollTop 和 scrollLeft 进行居中问题,没什么好说的,自己画画图就知道怎么算了,就是需要注意 scale 缩放值问题,也要考虑到这一点:
useEffect(() => {
// ...
autoLayoutCanvas();
}, []);
const autoLayoutCanvas = () => {
console.log(containerRef.current!.clientWidth);
// 40 是给画布留有间距,不让它占满整个容器
const containerWidth = containerRef.current!.clientWidth - 40;
const containerHeight = containerRef.current!.clientHeight;
// 计算容器、画布宽高比例
const containerRatio = parseFloat((containerWidth / containerHeight).toFixed(3));
const canvasRatio = parseFloat((width / height).toFixed(3));
let scale = 1;
if (canvasRatio > containerRatio) {
const scaleWidth = parseFloat((containerWidth / width).toFixed(3));
scale = scaleWidth > 1 ? 1 : scaleWidth;
} else {
const scaleHeight = parseFloat((containerHeight / height).toFixed(3));
scale = scaleHeight > 1 ? 1 : scaleHeight;
}
setScale(scale);
setLayoutPos(scale);
};
const setLayoutPos = (scale: number) => {
const { disX, disY } = computedDis();
containerRef.current!.scrollLeft += -disX - 20; // 偏移的 20 对应着上面的 40
containerRef.current!.scrollTop +=
-disY - (containerRef.current!.clientHeight - canvasRef.current!.clientHeight * scale) / 2;
};
为了展示自动布局的效果,我们给视图上随便定位一个按钮,点击它会调用 autoLayoutCanvas 函数,我们直接来看图吧:
动态设置标尺单位
其实可以看到上面动图缩放的过程,当我们缩放时标尺的刻度也太挤了,缩小到一定程度都要看不清了,这跟我们设置的单位有关
react-ruler 提供了 unit
属性,默认值就是我们看到的 50,我们希望当缩小时这个单位设置的大一些,放大时单位小一些,这样刻度看起来更正常一些🤗
所以直接根据 scale 动态改变即可:
const computedUnit = useMemo(() => {
if (scale > 1.5) return 25;
else if (scale > 0.75 && scale <= 1.5) return 50;
else if (scale > 0.4 && scale <= 0.75) return 100;
else if (scale > 0.2 && scale <= 0.4) return 200;
else return 400;
}, [scale]);
把这个值绑定到 ruler 组件上,再来看看效果:
可以看到这时候缩放刻度就没有那么挤了,完美!🥰
动态改变标尺尺寸
最后的最后,还有一个小的功能没有讲,其实就是当屏幕尺寸发生改变时,标尺的刻度仍然是按照之前屏幕的大小,所以我们需要动态调整,react-ruler 组件实例上有一个 resize 方法正好满足我们的需求,所以直接开搞,监听屏幕尺寸发生变化重置:
const verticalRulerRef = useRef<null | Ruler>(null);
const horizontalRulerRef = useRef<null | Ruler>(null);
useEffect(() => {
// ...
window.addEventListener("resize", handlePageResize);
}, []);
const handlePageResize = () => {
verticalRulerRef.current?.resize();
horizontalRulerRef.current?.resize();
};
给 ruler 绑定 ref 的 JSX 代码就省略了~ 😶
End
到此标尺组件的二次封装就结束咯😸😸😸,当然这只是一些简单功能的实现 demo,代码没有怎么组织,应用到自己的项目中还是需要额外封装的,所以按照自己的业务场景进行扩充即可,最后源码奉上👇:
DrssXpro/react-ruler-demo: 二次封装 react-ruler,实现基本低代码操作效果 (github.com)
原文链接:https://juejin.cn/post/7324992318418845759 作者:討厭吃香菜