需求:从左侧面板中拖拽控件到右侧画布区域,然后生成一个拖拽控件类型控件,需要修改拖拽时的样式
因为没有找到合适的方式去修改拖拽中的样式和拖拽到非画布区域返回动画,所以手写了个utils
ps: 改utils是ts版本的,如果需要js版本的,可以手动编译为js
/**
* 使用原生鼠标移动事件模拟拖拽
*/
type Nullable<T> = T | null | undefined
// 监听的事件类型
type WatchEvent = 'before' | 'start' | 'moving' | 'end'
// 这几个类型判断可以放到 公用的 utils 中
const isWatchEvent: Function = (type: unknown): type is string => typeof type === 'string'
const isWatchEventList: Function = (type: unknown): type is Array<WatchEvent> => type instanceof Array && type.some((s: unknown) => isWatchEvent(s))
const isFunction: Function = (type: unknown): type is Function => type instanceof Function && typeof type === 'function'
interface Component {
name: string,
// 图标
icon?: any,
// 控件类型
type?: string,
// 控件id
id: string | number
}
interface DragOptions {
dragList: Array<HTMLElement> | HTMLElement,
container?: HTMLElement,
// 生成拖拽dom的数据 (控件列表)
componentList?: Array<Component | any>,
dragClass?: string,
isDeleteDrag?: Boolean,
isInit?: Boolean
}
interface WatchItem {
event: WatchEvent,
callback: Array<Function>
}
interface Position { x: number, y: number }
class SimulationDrag {
readonly watchEvent: Array<WatchEvent> = ['before', 'start', 'moving', 'end']
private readonly _id = 'data-dragId'
// 可拖拽集合 Dom元素
private _dragList: Array<HTMLElement>
// 需要拖拽到的容器
private _container!: HTMLElement
// 控件列表
private _componentList!: Array<Component>
// 拖拽要改的样式 calss
private _dragClass?: string
// 拖拽中的元素
private _dragIngDom: Nullable<HTMLElement>
// 拖拽元素的父元素
private _parent: HTMLElement
// 拖拽中的位置
private _position: Position = {x: 0, y: 0}
// 是否删除拖拽到容器内部生成的元素 true: 删除, false:不删除
private _isDeleteDrag: Boolean = false
// 监听列表
private _watchList: Array<WatchItem> = []
// 是否自动初始化 如果自动初始化,则不会触发 before 事件
private _isInit: Boolean = true
constructor (optins: DragOptions) {
this._dragList = optins.dragList instanceof Array ? optins.dragList : [optins.dragList]
// 获取当前拖拽集合的第一个元素的父元素
const parentElement: Nullable<HTMLElement> = this._dragList[0].parentElement
// 没有就默认为 父元素
this._parent = parentElement ?? document.body
// 默认为 document.body
this._container = optins.container ?? document.body
// 给每个元素增加自定义属性用于区分
this._componentList = optins.componentList ?? []
this._dragClass = optins.dragClass
this._isDeleteDrag = <Boolean>optins.isDeleteDrag
// 是否手动初始化
if (optins.isInit !== undefined) {
this._isInit = optins.isInit
}
this._addData_id()
this._isInit && this._initMouseEvent()
}
// 手动初始化
public init () {
this._initMouseEvent()
}
/**
* clone dom 元素
* @param {HTMLElement} brother 需要克隆的元素
*/
private _cloneDom (brother: HTMLElement) :HTMLElement | Node {
const {left, top, width, height} = brother.getBoundingClientRect()
// clone一份元素
const children: HTMLElement = <HTMLElement>brother.cloneNode(true)
// 复制一份原来控件的类名
const classList: string = brother.classList.toString()
// 给clone的元素加上这个类名
children.classList.add(classList)
// 增加自定义类
this._dragClass && children.classList.add(this._dragClass)
// 阻止原生拖拽,否则会影响模拟拖拽
children.ondragstart = () => false
// 可以通过 dragClass 覆盖样式
this._setStyle(children, {
position: 'fixed',
left: left + 'px',
top: top + 'px',
width: width + 'px',
height: height + 'px',
cursor: 'all-scroll',
zIndex: '2021'
})
return children
}
// 给dom增加自定义属性
private _addData_id () {
const getId: any = (index: number) => this._componentList?.[index]?.id ?? new Date().valueOf().toString()
this._dragList.forEach((item: HTMLElement, index: number) => item.setAttribute(this._id, getId(index) as string))
}
// 初始化鼠标事件
private _initMouseEvent () {
// 初始化事件的时候,触发 before 事件,如果想要执行 before 事件,需要将 isInit 设置为 false,并且将监听放到 init 之前
this.emitWatch('before')
// 给document绑定事件(因为 mousemove 触发有时间间隔,会导致元素移动跟不上鼠标从而失效)
document.addEventListener('mousedown', (e: MouseEvent) => this.dragEvent(e))
document.addEventListener('keydown', () => this.removeDrag())
}
// 绑定的拖拽事件
public dragEvent (e: MouseEvent) {
// 非拖拽元素触发事件不予理会
if (!this._parent.contains(<Node>e.target)) return
// 开始事件
this.emitWatch('start', e)
// 因为有选中内容会导致拖动有bug,所以每次点击的时候把页面选中的状态取消
document.getSelection()?.empty()
const target: HTMLElement = <HTMLElement>e.target
// 找到当前触发鼠标按下事件的元素。
const dragDom: Nullable<HTMLElement> = this._dragList.find((drag: HTMLElement) => drag === target || drag.contains(target))
if (dragDom) {
this._dragIngDom = <HTMLElement>this._cloneDom(dragDom)
// 将复制过来的元素放到拖拽元素的位置
this._parent?.appendChild(this._dragIngDom)
// 初始化坐标位置
this._position.x = e.clientX
this._position.y = e.clientY
// 将事件提取出来,要不然会导致 removeEventListener 的时候失效
const _dragMoving = this._dragMoving.bind(this)
// 拖拽元素移动事件
document.addEventListener('mousemove', _dragMoving)
// 鼠标松开的时候需要删除拖拽的元素
document.onmouseup = (ev: MouseEvent) => {
if (!this._parent.contains(<Node>ev.target)) return
document.removeEventListener('mousemove', _dragMoving)
// 判断当前控件是否在容器中,不再则删除
!this.judgePosition() && this.removeDrag()
this.emitWatch('end', ev)
}
}
}
// 删除拖拽的元素
public removeDrag () {
this._dragIngDom?.parentElement?.removeChild(this._dragIngDom)
}
/**
* 根据鼠标移动拖拽元素
* @param ev 鼠标移动的事件参数
*/
private _dragMoving (ev: MouseEvent) {
document.getSelection()?.empty()
if (this._dragIngDom) {
// 开始事件
this.emitWatch('moving', ev)
const {clientX, clientY} = ev
const left = `${parseFloat(this._dragIngDom.style.left) + clientX - this._position.x}px`
const top = `${parseFloat(this._dragIngDom.style.top) + clientY - this._position.y}px`
this._setStyle(this._dragIngDom, {left, top})
this._position.x = clientX
this._position.y = clientY
}
}
/**
* 判断当前移动的控件是否放入到了容器中
* @returns 返回结果
*/
private judgePosition (): Boolean {
// 如果设置了 _isDeleteDrag 则不删除
if (this._isDeleteDrag) return !this._isDeleteDrag
// 如果是没有设置容器,意味着可以随意拖拽。
if (this._container === document.body) return true
const {left: Pleft, top: Ptop, right: Pright, bottom: Pbottom} = <DOMRect>this._container?.getBoundingClientRect()
const {left: Cleft, top: Ctop, right: Cright, bottom: Cbottom} = <DOMRect>this._dragIngDom?.getBoundingClientRect()
// 判断是否完全拖拽到控件上
return (Cleft >= Pleft && Cright <= Pright) && (Ctop >= Ptop && Cbottom <= Pbottom)
}
/**
* 设置样式
* @param {HTMLElement} dom 需要设置样式的 dom
* @param {Object} styles 需要设置的样式
*/
private _setStyle (dom: HTMLElement, styles: Object) {
Object.assign(dom.style, styles)
}
/**
* 监听事件回调
* @param watchType 需要监听的类型,如果不传递默认为监听全部,可以监听单个或者以数组形式监听某几个
* @param callback 事件回调,如果没有传递监听,则callback为第一个参数
*/
public watch (watchType: Array<WatchEvent> | WatchEvent | Function, callback?: Function): void | never {
// 判断是否传递了监听类型
if (isWatchEventList(watchType) && callback && isFunction(callback)) {
this.addWatch((watchType as Array<WatchEvent>), callback)
} else if (isWatchEvent(watchType) && callback && isFunction(callback)) {
this.addWatch([(watchType as WatchEvent)], callback)
} else if (isFunction(watchType)) {
// 如果是方法,则默认监听全部事件
this.addWatch(this.watchEvent, <Function>watchType)
} else {
throw new Error(`${watchType} is unknown`)
}
}
/**
* 给 watchList 增加回调
* @param watchType 需要处理的数组
* @param callback 回调
*/
private addWatch (watchType: Array<WatchEvent>, callback: Function) {
watchType.forEach((event: WatchEvent) => {
const watchItem: Nullable<WatchItem> = this._watchList?.find((item: WatchItem) => event === item.event)
// 如果能找到,则往callbakc中继续增加回调,否则新建类型
if (watchItem) {
watchItem.callback.push(callback)
} else {
this._watchList?.push({
event,
callback: [callback]
})
}
})
}
/**
* 根据 event 触发相应事件
* @param event 需要触发的事件
*/
private emitWatch (event: WatchEvent, args?: unknown) {
if (args && typeof args === 'object') {
args = Object.assign(args, {
eventType: event,
position: {...this._position}
})
}
// 如果有事件回调存在
if (this._watchList.length) {
const callbacks: Nullable<Array<Function>> = this._watchList.find((item: WatchItem) => item.event === event)?.callback
callbacks?.forEach((callback: Function) => callback(args))
}
}
}
export default SimulationDrag
执行
<template>
<div class="component-list-content">
<div class="search">
<el-input v-model="keywords" placeholder="请输入" clearable></el-input>
</div>
<div class="component-list">
<div class="title">控件列表</div>
<div class="components">
<div :ref="getDrag" class="component-item" v-for="component in getComponentList(keywords)" :key="component.id">
<img class="component-img" :src="component.icon">
<span class="component-name">{{component.name}}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, Ref, onMounted, computed, ComputedRef } from 'vue'
// 默认图标
import componentIcon from '@/assets/images/component.png'
type ComponentItem = {
name: string,
id: string | number,
type: string,
icon?: any,
activeIcon?: any
}
import SimulationDrag from '@utils/drag/index'
export default defineComponent({
name: "component-list",
setup () {
const keywords: Ref<string> = ref('')
const componentList: Ref<Array<ComponentItem>> = ref([])
const dragList: Array<HTMLElement> = []
const getDrag = (el: HTMLElement) => {
if (el) {
dragList.push(el as HTMLElement)
}
}
// 添加测试数据
for (let i: number = 0; i < 50; i++) componentList.value.push({
name: `控件-${i + 1}`,
id: i,
type: 'input',
icon: componentIcon,
activeIcon: componentIcon
})
// 计算展示控件列表
const getComponentList:ComputedRef = computed(() => (keywords: string): Array<ComponentItem> => componentList.value.filter((f: ComponentItem) => f.name.includes(keywords)))
onMounted(() => {
// 初始化拖拽事件
const drag: SimulationDrag = new SimulationDrag({
dragList,
componentList: componentList.value,
dragClass: 'custom-draggable-style',
container: document.getElementById('componentMain') as HTMLElement
})
// 执行拖拽监听,获取当前拖拽的位置
drag.watch((e: any) => {
console.log(e.position)
})
})
return {
keywords,
componentList,
getComponentList,
getDrag
}
}
})
</script>
<style lang="scss" scoped>
.component-list-content {
@extend .wh100p;
background-color: #fff;
box-shadow: $design-shadow;
@extend .flex-column;
.search {
width: 100%;
padding: 10px;
}
.component-list {
padding-top: 10px;
overflow: auto;
flex: 1;
.title {
padding-bottom: 10px;
border-bottom: 1px solid #ccc;
}
.components {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
.component- {
&item {
@extend .flex-column;
width: 33%;
height: 70px;
text-align: center;
padding: 10px;
align-items: center;
}
&img {
width: 12px;
height: 12px;
margin-bottom: 15px;
}
}
.custom-draggable-style {
background-color: $default-color2;
}
}
}
}
</style>
运行效果