缘起
最近在开发一个思维导图的库blink-mind-react,在开发这个库的过程中,由于思维导图区域需要可以在视图里面自由移动,实现DragScroll效果。所以开发了这么一个组件。
先看最终要的效果
通过按下鼠标移动可以将思维导图拖动到视口中任何一个位置
由于思维导图控件有些许复杂,我写了一个更简单的demo, 仅仅拖动一个正方形色块
想要运行demo,在这个库的目录下运行
yarn storybook
实现过程
这个组件的代码在这里
使用示例
给要封装的DragScroll组件取一个名字叫做DragScrollWidget,在拖动正方形色块的例子中,demo的代码如下:
import * as React from "react";
import { DragScrollWidget } from "../../src/component/common/DragScrollWidget";
export default class Demo2 extends React.Component {
render() {
return (
{() => (
)}
);
}
}
为什么我们封装的DragScrollWidget,示例代码中传给DragScrollWidget的children是一个函数而不是一个React.Element呢,写成下面这样呢?
这是因为在实现思维导图的过程中一些特别的需求需要,暂且不表。
render函数
下面分析DragScrollWidget内部是怎么实现的,DragScrollWidget的render() 函数如下:
render() {
return (
{this.props.children(
this.setViewBoxScroll,
this.setViewBoxScrollDelta
)}
);
}
最外层的classname="drag-scroll-view"的div表示视口区域,
第二层的div 没有classname , 他的ref是bigView 表示可拖动区域,这个区域的宽度和高度必须比视口区域的高宽都要大,否则就不存在拖动这回事了,
它最开始的size,暂时写死成一个很大的size。在构造函数中将widgetStyle设置成一个非常大的size。
constructor(props: DragScrollWidgetProps) {
super(props);
this.state = {
widgetStyle: {
width: "10000px",
height: "10000px"
}
};
}
后面再说怎么去根据视口区域大小和真正可拖拽的内容的实际大小来计算出合适的bigView区域的size
第三层的classname="drag-scroll-content"的div是为了在我们要拖动的内容的size 发生变化时候让整个控件能够作出响应。具体怎么响应现在也暂时不表。如果可拖拽内容的size固定不发生变化。那么这个div 可以忽略。
最里面的函数调用表达式式为了渲染该控件的children。至于this.setViewBoxScroll和this.setViewBoxScrollDelta是什么暂时也不表。
里面的三个ref函数代码如下:
content: HTMLElement;
contentRef = ref => {
if (ref) {
this.content = ref;
this.contentResizeObserver.observe(this.content);
}
};
viewBox: HTMLElement;
viewBoxRef = ref => {
if (ref) {
this.viewBox = ref;
this.setViewBoxScroll(
this.viewBox.clientWidth,
this.viewBox.clientHeight
);
}
};
bigView: HTMLElement;
bigViewRef = ref => {
if (ref) {
this.bigView = ref;
}
};
目前只需要了解设置ref到class内部的viewBox,bigView,content变量即可,至于this.contentResizeObserver.observe(this.content)和setViewBoxScroll调用的目的是什么后面会细说。
来看一下里面几个css
.drag-scroll-view {
position: relative;
width: 100%;
height: 100%;
overflow: scroll;
}
.drag-scroll-content {
position: relative;
width: max-content;
}
很简单就不做多解释了。
拖动事件响应
下面分析当鼠标按下拖动的事件响应是怎么实现的,
最外层的classname="drag-scroll-view"的div 绑定了一个onMouseDown事件
来看下这个onMouseDown函数
onMouseDown = e => {
// mouseKey 表示鼠标按下那个键才可以进行拖动,左键或者右键
// needKeyPressed 为了支持是否需要按下ctrl键,才可以进行拖动
// canDragFunc是一个函数,它是为了支持使用者以传入函数的方式,这个函数的返回值表示当前的内容是否可以被拖拽而移动
let { mouseKey, needKeyPressed,canDragFunc } = this.props;
if(canDragFunc && !canDragFunc())
return;
if (
(e.button === 0 && mouseKey === "left") ||
(e.button === 2 && mouseKey === "right")
) {
if (needKeyPressed) {
if (!e.ctrlKey) return;
}
this._lastCoordX = this.viewBox.scrollLeft + e.nativeEvent.clientX;
this._lastCoordY = this.viewBox.scrollTop + e.nativeEvent.clientY;
window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("mouseup", this.onMouseUp);
}
};
当鼠标按下时候给window 对象添加两个事件mousemove和mouseup。
_lastCoordX和_lastCorrdY的作用是什么呢?
// _lastCoordX和_lastCorrdY 是为了在拖动过程中 计算 viewBox的scrollLeft和scrollTop值用到
// _lastCoordX和_lastCorrdY 记录下拖动开始时刻viewBox的scroll值和鼠标位置值
_lastCoordX: number;
_lastCoordY: number;
在mousemove过程中就可以根据当前鼠标的位置值和刚开始移动时刻位置的差值加上最开始移动时候viewBox的scroll值来计算出viewBox的新的scroll值
onMouseMove = (e: MouseEvent) => {
this.viewBox.scrollLeft = this._lastCoordX - e.clientX;
this.viewBox.scrollTop = this._lastCoordY - e.clientY;
};
鼠标松开时移除事件监听
onMouseUp = e => {
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("mouseup", this.onMouseUp);
};
这样最基本的可拖动移动位置的控件便完成了。
计算合适的size
最开始的时候bigView的size是一个很大的值,是因为这个值不是很重要,后面还需要根据可拖拽的内容的实际大小来进行计算,
计算的函数如下:
setWidgetStyle = () => {
if (this.content && this.viewBox && this.bigView) {
this.bigView.style.width =
(this.content.clientWidth + this.viewBox.clientWidth) * 2 + "px";
this.bigView.style.height =
(this.content.clientHeight + this.viewBox.clientHeight) * 2 + "px";
this.content.style.left = this.viewBox.clientWidth + "px";
this.content.style.top = this.viewBox.clientHeight + "px";
}
};
这个函数里面把bigView的size 设置成(实际内容的size+视口区域的size)*2,
这样做的目的是为了当实际内容在某个方向上完全离开视口空间时,可拖动区域在那个方向上便不可以在拖动了,因为即使再拖动也看不到了,还不如让它不可以再拖动。
setWidgetStyle函数需要在this.content , this.viewBox , this.bigView 都不为null的时候才可以被调用,也就是
componentDidMount这个生命周期函数中被调用
componentDidMount(): void {
this.setWidgetStyle();
document.addEventListener("contextmenu", this.handleContextMenu);
}
componentWillUnmount(): void {
document.removeEventListener("contextmenu", this.handleContextMenu);
}
handleContextMenu = e => {
e.preventDefault();
};
因为我们需要在这个控件中禁用浏览器自带的右键菜单。
待续
后面关于这个控件支持根据内容区域的实际大小的改变而做出响应,以及关于思维导图开发过程中的特殊需求而设计的代码,在后面的文章中继续分析