低代码定制拖拽场景解决方案

644 阅读11分钟

低代码引擎作为各种低代码平台场景的底层支持,那么拖拽作为低代码场景最为关键的能力之一,封装一个低代码场景提供一种通用性、可拓展的拖拽能力必然重要!!!

拖拽事件选型

前端完成元素的拖拽有两种方式:MouseEventDragEvent
两者功能都足以满足低代码下的所有场景,区别就是DragEvent更好的支持了数据传输,而这也造成了它性能上需要付出更多损耗,而MouseEvent也能通过封装完成拖拽过程中的数据传输且更轻量级,因此鉴于性能考虑,优先选择MouseEvent作为拖拽的交互事件

封装拖拽事件对外能力

首先我们需要将mousedownmousemovemouseup封装起来

  • 需要一个完整的拖拽交互动作逻辑【按下】【拖动】【松开】
  • 给外部传入的【拖拽区域元素】绑定交互逻辑
/*
* target 将视为拖拽的操作区域
*/
addSensor(target: HTMLElement | Window) {
  // 按下时绑定mousemove和mouseup事件
  target.addEventListener('mousedown', () => {
    target.addEventListener('mousemove', () => {})
    // 松开时清除mousemove和mouseup事件
    target.addEventListener('mouseup', () => {
      target.removeEventListener('mousemove')
      target.removeEventListener('mouseup')
    })
  })
	// 返回去除mousedown的事件
  return () => removeEventListener('mousedown')
}

这样最基本的拖拽封装就完成了!

识别拖拽区域和拖拽节点

首先我们先了解下低代码中的拖拽场景(当然不限于此)

  • 自身区域拖拽,包括大纲树节点排序、画布节点位置变更等等。
  • 跨区域拖拽,包括物料拖入画布等等

可以从场景中,我们看到了一些“角色”元素

  • 拖拽节点:被鼠标拖拽的元素,包括
    • 占位元素,包括物料、素材等等。
    • 节点,画布节点、大纲树节点等等
  • 拖拽区域:包裹拖拽节点的容器,包括物料列表、画布、大纲树等等

--- 当一个页面出现了同类概念产物的时候,我们就需要通过标记来区分它们 ---

确定是否是可拖拽节点

首先从我们提供的addSensor API可知,我们只能拿到一个绑定的拖拽区域元素,以及其能够触发的交互事件,也正因为不是直接对可拖拽元素绑定事件,导致我们触发事件不一定是拖拽节点 截屏2023-07-26 12.28.24.png

所以我们只有从冒泡上来仅有的event信息来进行判断
既然要在event上拿到绑定在元素上的信息,那么第一时间想到的就是在元素上加上data-*的标记

<div data-node-id="xxxx"></div>

然后根据标记线索进行以下逻辑的判断

  • 触发的是Sensor下的空白区域,就不是拖拽节点
  • 触发的是拖拽节点的子元素,应该向上查找到正确的拖拽节点
  • 携带标记的元素就是拖拽节点
sensor.addEventListener('mousedown', (event) => {
  // closest能够向上查找匹配的元素(包含本身),查找到就停止,直至到document元素,则返回null
  const dragElm = event.target.closest('[data-node-id=*]')
})

虽然可以确定是否是可拖拽元素,但是还是不知道该元素是组件节点元素还是物料一样的占位元素? 首先根据两者不同的性质,需要定义两个不同的数据结构,也是包含了节点的数据信息

// 物料相关占位元素
interface IDragNodeData {
  type: 'nodedata',
  data: Record<'componentName' | string, any> // 需要被转化成组件的物料信息
}
// 生成组件的节点元素
interface IDragNode {
  type: 'node',
  data: INode[] // 被转化成组件后的schema信息
}

那么哪里才能拿到这个信息呢?往往低代码场景中,不存在一个拖拽区域同时存在该两种节点的情况,所以我们就可以把这个信息放到注册Sensor的地方,也就是addSensor API,添加boost参数接收节点信息

type boost = (dragElm: HtMLElement) => IDragNodeData | IDragNode | null

class DragHandler {
  sensorBoostMap: new WeakMap()
  // 添加boost参数能够拿到节点信息
  addSensor(target, boost) {
    /* bind sensor handler */
    sensorBoostMap.set(target, boost)
    
    // 将区域元素绑定到了操作事件中,就知道当前的事件是哪个区域元素触发的
    const bindMousedownWithSensor = (event) => handleMousedown(event, target)
    target.addEventListener('mousedown', bindMousedownWithSensor)
  }
  handleMouseDown(event, sensor) {
    const dragElm = /* 拿到可拖拽元素 */
    const boost = sensorBoostMap.get(sensor)
    const dragObject = boost(dragElm) // 物料数据 | schema数据
  }
}
addSensor('物料容器', (dragElm: HtMLElement): IDragNodeData => {
   const nodeId = dragElm.dateset.nodeId
   return {
       type: 'nodedata',
       data: {} // 物料数据
   }
})
addSensor('画布容器', (dragElm: HtMLElement): IDragNodeData => {
   const nodeId = dragElm.dateset.nodeId
   return {
       type: 'node',
       data: {} // schema数据
   }
})

确定当前操作的区域身份

和boost同样的道理,我们也可以在Sensor注册的时候,将区域的信息注册进来

class DragHandler {
  sensorBoostMap: new WeakMap()
  // 添加options参数作为区域信息
  addSensor(target, boost, options = {}) {
    /* bind sensor handler */
    sensorBoostMap.set(target, { boost, ...options })
  }
  handleMouseDown(event, sensor) {
    const boost = sensorBoostMap.get(sensor).alias // 区域别名
  }
}
// 物料容器
addSensor(target, boost, { alias: 'material' })

借助触发回调来提升拓展性

因为所有的事件都被封装了起来,所以外部平台并不能知晓当前的交互操作,从而设计不同场景下的需求交互
因此需要一个发布订阅的方式,当某个事件触发时,能够通知到对应的业务逻辑

import mitt from 'mitt'
const emitter = new mitt()
class DragHandler {
  addSensor(target) {
    target.addEventListener('mousedown', () => {
      emitter.$emit('MOUSE_DOWN')
    })
  },
  addDragEventListener(eventKey: 'mousedown' | 'mousemove' | 'mouseup', callback) {
    // 注册不同事件回调
    emitter.$on(eventKey, callback)
  }
}
// 业务就可以根据相应的事件触发 不同的业务行为
dragHandler.addDragEventListener('MOUSE_DOWN', (ctx) => {
  // 当mousedown节点的时候选中节点
  if (ctx.xxxx === 'xxxx') selectNode(ctx.xxx.nodeId)
})

包装通用数据信息

上文提到了MouseEvent事件比DragEvent事件缺少数据传输,而我们往往在拖拽场景中确实存在这个需求,比如丰富事件的信息,所以需要重新包装一下event信息,并通过回调传递数据

interface IDragLocalEvent extends MouseEvent {
  startX: number
  startY: number
  distX: number
  distY: number
  // ...
}

class DragHandler {
  dragEventInfo: IDragEventInfo

  addSensor(target, boost, options) {
    target.addEventListener('mousedown', (event) => {
      /** reset dragEventInfo Data */
      event.mouseEventName = 'mousedown'
      createLocalEvent(event)
      emitter.$emit('MOUSE_DOWN', { event, dragEventInfo })
    })
    target.addEventListener('mousemove', (event) => {
      event.mouseEventName = 'mousemove'
      createLocalEvent(event)
      emitter.$emit('MOUSE_MOVE', { event, dragEventInfo })
    })
    
    addDragEventListener(eventKey: 'mousedown' | 'mousemove' | 'mouseup', ctx: IDragContext) {
      // 注册不同事件回调
      emitter.$on(eventKey, () => callback(ctx))
    }
  }

  createLocalEvent(event) {
    if (event.mouseEventName === 'mousedown') {
      dragEventInfo.startX = event.clientX
      dragEventInfo.startY = event.clientY
    }
    dragEventInfo.distX = event.clientX - dragEventInfo.startX
    dragEventInfo.distY = event.clientY - dragEventInfo.startY
  }
  
  return event
}

除了位置,当然还少不了元素相关的信息

interface IDragContext {
  dragElement: HTMLElement // 被拖拽元素
  pointElement?: HTMLElement // 鼠标位置下的元素
  formSensor: HTMLElement | Window // 来自哪个拖拽区域
  toSensor: HTMLElement | Window // 当前操作的区域
  event: IDragLocalEvent
  ...
}
target.addEventListener('mousedown', (event) => {
  const dragObject = boost(dragElm) // 物料数据 | schema数据
  const dragInfo = computed(event) // 计算出各种拖拽过程中的数据
  emitter.$emit('MOUSE_DOWN', { event, dragEventInfo, dragObject, ...dragInfo })
})

这样外部回调能够得到的信息就更多了,可以处理的事情也就越多了。
功能提供了,接下来我们就需要接入低代码真实拖拽场景了!

拓展不同的拖拽场景能力

  • 不同场景有不同的编排规则
    • 自由布局编排:任意改变节点位置
    • 流式布局编排:对应节点的尺寸进行水平流式排版
    • 网格布局编排:对应节点的占用网格进行竖向流式排版
    • ...
  • 不同场景有不同的插入规则
    • 自由布局编排:节点之间往往不影响,根据移入位置插入
    • 流式布局编排:根据鼠标下的元素进行是兄弟节点前后插入还是作为子节点插入容器
    • 网格布局编排:根据鼠标下的元素进行兄弟节点前后插入
    • ...

那么从技术的角度,我们可以将上述场景定义为一些规则,首先可以分析出不同编排需要的数据信息

  • 自由布局编排:内容拖拽后的位置信息
  • 流式布局编排:父元素、兄弟前后元素
  • 网格布局编排:x:横排起始位置(单位一格),y:竖排起始位置,w:横排占的格数, h:竖排占的格数
  • ...

结合节点插入规则

自由布局:就是更新原先位置

addDragEventListener('mouseup', (ctx) => {
  // 自由布局就可以根据传参中的size修改来改变节点位置
  node.setProps('size', ctx.updateSizeInfo)
}

流式布局&网格布局:将节点插入到指定位置

// 可以按照优先级插入到参考节点位置
addDragEventListener('mouseup', (ctx) => {
  if (ctx.prevSiblingElm) {
    // 有前兄弟节点,那么就插入到该兄弟节点后面
    insertAfter(node, preNode)
  } else if (ctx.nextSiblingElm) {
    // 有后兄弟节点,那么就插入到该兄弟节点后面
    insertBefore(node, nextNode)
  } else {
    // 没有兄弟节点,那么就插入到容器节点下(整个画布也是一个父容器)
    parentNode.insert(node)
  } 
})

至于插入后的展示就交给渲染能力去实现了!我们只需要将数据按照低代码协议定义好就好。
搞清楚插入的逻辑后,我们就需要设计编排的规则了,然后把编排的结果告诉插入规则即可。

定义自定义编排规则

首先我们需要支持一个 addDragRule API作为规则注入的接口,能够外部定义任意编排规则

interface DragRule {
  // 满足规则的条件
  condition: (ctx: IDragContext) => boolean | boolean
  // 满足条件后执行的内容,会将结果作为参数传递给回调函数
  consequence: (ctx: IDragContext) => IDragContext 
}

interface IDragArrangeRuleDTO {
  ruleName: string // 规则名称,作为指定规则时调用的标识认定
  rules: DragRule // 具体的规则定义
}

class RuleManager {
  addDragRule(rule: IDragArrangeRuleDTO) {}
}

比如我需要定义一个简单的流式布局规则

const FLOW_LAYOUT_RULE: DragRule = [
  // 鼠标指针下的元素是容器节点
  {
    condition: ({ pointElement }) => pointElement.dataset.nodeContainerId ? true : false,
    consequence: () => {
      return {
        // 根据设置的插入规则,我们只需要将父节点设置为当前的容器节点,那么就会将拖拽节点插入到该容器节点下面
        parentElement: pointElement
      }
    },
  },
  {
    // 鼠标指针下的元素是节点
    condition: ({ pointElement }) => pointElement.dataset.nodeId ? true : false,
    consequence: () => {
      // 计算鼠标在当前节点的偏左还是偏右
      const { nearSiblingElm, isPrev } = computedNearRect()
      if (isPrev) {
        // 如果是在节点的左边,那么就将当前鼠标下的节点作为后兄弟节点,就会在其前面插入
        return { nextSiblingElm: nearSiblingElm }
      } else {
        // 如果是在节点的左边,那么就将当前鼠标下的节点作为前兄弟节点,就会在其后面插入
        return { prevSiblingElm: nearSiblingElm }
      }
    }
  }
]

自由布局和网格布局,甚至更多的布局范式都是如此,只要编写所需的拖拽规则和插入规则就可以完成一整套编排能力

拖拽规则的应用

一个拖拽区域内的编排方式往往是一致的,但是同页面下不同的区域可能存在不同的情况,比如可视化低代码平台既有中间自由布局,又有图层树的结构布局
因此我们希望在拖拽的过程中,能够根据区域的预设,切换编排方式,那么我们就要讲编排规则的维度更加细化,以 拖拽区域 => 场景 => 项目的优先级来匹配拖拽规则

class RuleManager {
  private excuteRule() {
    // 以优先级匹配注入的拖拽规则名,并执行匹配到的规则
    const ruleName = sensor?.option?.layout || doc.config.layout || project.config.layout
    const rules = getRule(ruleName)
  }
}

这样就能满足业务的需要,并且可以由业务自身来配置相应的规则达到想要的效果。

多节点拖拽场景

  • 如果是自由布局那么就是多个节点处理相同方向相同距离的位移
  • 如果是流式布局,那么就是将多个节点一起插入到一个位置
addSensor(target, (dragElm) => {
  // 拿到多个节点的逻辑:比如selectNodes作为选中的存储,selectNodes.push(node)
  return {
    type: 'node',
    data: selectNodes // 返回多个节点
  }
})

自由布局改造

对于每一个元素都改变位置,并修改插入规则,改变每一个node的size

// 按下时,获取所有选中节点的DOM元素
addDragEventListener('mousedown', () => {
  event.dragElements = dragObject.nodes.map(item => document.querySelector(`[data-node-id=${item.id}]`)
})
// 拖动时,改变所有DOM元素位置
addDragEventListener('mousemove', () => {
  event.dragElements.forEach(elm => {
    elm.style.transform = translate(x, y)
  })
})
// 插入规则也变成遍历所有节点
invokeNodeInsertHandler(ctx: IDragContext) {
  nodes.forEach(() => {
    node.setProps('size', ctx.updateSizeInfo)
  })
}

流式布局改造

直接将数组node按优先级插入到根据规则得出的位置

invokeNodeInsertHandler(ctx: IDragContext) {
  if (ctx.prevSiblingElm) {
    insertAfter(nodes, preNode)
  } else if (ctx.nextSiblingElm) {
    insertBefore(nodes, nextNode)
  } else {
    parentNode.insert(nodes)
  } 
}

性能优化&能力拓展

回调可以有它的归宿

可以通过设置事件回调的区域限制来减少其触发的频率。

class DragHandler {
  addDragEventListener(eventName, fn, options: string | string[]) {
    /* handle options scopeSensor */
    if (options.scopeSensor) {
      emmiter.$on(eventName + options.scopeSensor)
    }
  }  
}
addDragEventListener('mousemove', () => {}, { scopeSensor: 'sandbox' })

不是每个回调都要积极响应

不是每一个回调都要跟随像mousemove这样高频率触发的,所以可以对函数进行节流操作

class DragHandler {
  addDragEventListener(eventName, fn, options: string | string[]) {
    if (options.delay) {
      fn = throttle(fn, options.delay)
    }
    /* do something */ 
  }
}
addDragEventListener('mousemove', () => {}, { delay: 50 })

打断组件内部事件

部分组件本身存在一些交互行为,比如Select组件,点击会出现下拉菜单,又或者一些地图组件,在拖拽时,会和内部冲突等等,所以应该在操作时阻断这类行为

addSensor(target) {
  // 先捕获,再阻止冒泡
  target.addEventListener('mousedown', handleMousedown, true)
}
handleMousedown(event) {
  event.stopPropatation()
}

取消拖拽默认事件

总有场景是不想拖拽的,比如物料拖拽元素内部有个放大镜功能的子元素,那么按之前的逻辑,就会执行向上找到拖拽元素,而这并不是我们想看到的。

1.可以在event上添加属性,作为禁止标识

handleMousedown(event) {
  if(event.__preventDefaultDragHandler__) return
}

但是并不是所有场景都有能力添加处理event,比如封装的组件,本身就没抛出相应的事件

2.通过自身判断原生元素,返回布尔值来确定是否取消Drag处理事件

addSensor(target, boost, {
  preventDefult: (event) => {
    if (event.target.className === 'xxx-icon') return true
    return false
  }
})

class Dragon {
  handleMousedown(event, sensor) {
    const preventDefultFn = findPreventDefault(sensor)
    if (preventDefultFn?.(event)) return
  }
}

提供更强大的交互事件控制能力

除了Mouse事件外,还可以将点击和双击事件加入到整个拖拽交互,这样就可以向外部直接提供对应的执行事件和控制整个交互事件执行的能力
mousedown -> mousemove -> mouseup -> click => dblclick

interface preventBehaviour {
  // 控制自身是否执行
  isSelfBehaviourPrevent?: boolean
  // 控制后续是否执行
  isNextBehaviourPrevent?: boolean
}
addSensor('大纲树', boost, {
  eventControl: {
    mousedown: (event) => {
      // 这样我们就可以更加自定义的拓展业务场景
      if (node.dataset.notAllowDrag) {
        isSelfBehaviourPrevent: true,
        isNextBehaviourPrevent: true
      }
    },
    mousemove: (event) => {
      if (node.isLock) {
        return {
          isNextBehaviourPrevent: true
        }
      }
    },
  }
})
class DragHandle {
  handleMousedown() {
    const result = getSensorOptions(sensor)?.eventControl?.('mousedown')
    if (!result?.isNextBehaviourPrevent) {
      addEventListener('mousemove')
    }
    if (result?.isSelfBehaviourPrevent) return
    /** do something */
  }
}