Skip to content

Moveable(拖拽交互)的实现原理与关键代码

Moveable 是 Studio 低代码平台中负责组件拖拽交互的核心模块,它基于开源的 Moveable 库进行了二次封装和扩展,实现了组件精确定位、拖拽、缩放等功能。下面从实现原理和核心代码两方面进行详细解析:

1. 实现原理

1.1 核心设计思想

Moveable 采用了以下设计思想:

  • 组件化封装:将拖拽功能封装为独立的 MoveableManager 类
  • 事件驱动模式:通过事件监听和回调机制实现交互逻辑
  • 状态管理:维护拖拽目标和状态信息
  • DOM 操作抽象:封装底层 DOM 操作,提供简洁的 API

1.2 关键技术点

  • CSS 样式控制:通过直接操作 DOM 元素的 style 属性实现位置和大小的动态调整
  • 使用 CSS 类名和选择器识别可拖拽元素
  • 事件处理机制:监听鼠标事件,如 mousedown, mousemove, mouseup
  • 使用事件委托模式减少事件监听器数量
  • 元素选择与标识:使用自定义属性(如 instanceId)标识拖拽元素
  • 通过 CSS 选择器快速定位目标元素
  • 位置计算:使用 getBoundingClientRect()获取元素位置信息
  • 计算拖拽偏移量并应用到元素样式

2. 核心代码解析

2.1 MoveableManager 类的实现

MoveableManager 是整个拖拽交互的核心类,它封装了 Moveable 库的功能,并添加了特定于 Studio 的逻辑:

typescript
export class MoveableManager {
  private readonly rootClassName: string;
  private readonly groupClassName: string;
  private readonly targetClassName: string;
  private readonly moveableIdName: string;
  private readonly fixedClassName: string;
  public currentMoveable: any;
  private onUpdateCallback: Function;
  private onSelectCallback: Function;
  private onScrollEndCallback: Function;
  private rootTarget: Element;
  public onCancelCallback: Function;

  constructor({
    rootClassName,
    groupClassName,
    targetClassName,
    moveableIdName,
    fixedClassName,
  }) {
    this.rootClassName = getClassName(rootClassName);
    this.groupClassName = getClassName(groupClassName);
    this.targetClassName = getClassName(targetClassName);
    this.fixedClassName = getClassName(fixedClassName);
    this.moveableIdName = moveableIdName;
    this.currentMoveable = null;
  }

  // 注册回调函数
  onUpdate(onUpdateCallback: Function) {
    this.onUpdateCallback = onUpdateCallback;
  }

  onSelect(onSelectCallback: Function) {
    this.onSelectCallback = onSelectCallback;
  }

  onCancel(onCancelCallback: Function) {
    this.onCancelCallback = onCancelCallback;
  }

  onScrollEnd(onScrollEndCallback: Function) {
    this.onScrollEndCallback = onScrollEndCallback;
  }
}

2.2 初始化和组件查找

typescript
function init() {
  this.rootTarget = this.getRootTarget();
  if (this.rootTarget) {
    const groupTargets = document.querySelectorAll<HMTLElementWithMoveable>(
      `${this.groupClassName}`
    );
    groupTargets.forEach((group) => {
      group.isBindMoveEvent = false;
      if (!group.isBindMoveEvent) {
        group.isBindMoveEvent = true;
      }

      let targets: NodeListOf<HTMLDivElement>;
      // 判断如果是根组件,则直接获取子元素
      if (group.classList.contains(`${this.rootClassName.substring(1)}`)) {
        targets = group.querySelectorAll(`:scope > ${this.targetClassName}`);
      } else {
        // 如果是容器组件,则去下面第三层去查找
        targets = group.querySelectorAll(
          `:scope > * > * > ${this.targetClassName}`
        );
      }

      const movableTargets = Array.from(targets);
      this.initializeMoveableElements(group, movableTargets);
    });
  }
}

2.3 Moveable 实例配置与事件绑定

typescript
initializeMoveableElements(container: HMTLElementWithMoveable, targets: HTMLDivElement[]) {
  container.moveable = new Moveable(container, {
    draggable: true, // 是否允许元素拖拽
    resizable: true, // 是否允许元素调整大小
    snappable: true, // 是否启用吸附功能
    elementGuidelines: targets, // 元素对齐辅助线的目标数组
    horizontalGuidelines: [0, container.offsetHeight / 2, container.offsetHeight], // 水平辅助线
    verticalGuidelines: [0, container.offsetWidth / 2, container.offsetWidth], // 垂直辅助线
    keepRatio: false, // 调整大小时是否保持宽高比
    snapThreshold: 5, // 吸附的阈值(像素)
    scalable: false, // 是否允许元素缩放
    rotatable: false, // 是否允许元素旋转
    origin: false, // 是否使用原点作为移动和旋转的参考点
    snapDirections: { // 吸附方向
      top: true, left: true, bottom: true, right: true, center: true, middle: true,
    },
    elementSnapDirections: { // 元素吸附方向
      top: true, left: true, bottom: true, right: true, center: true, middle: true,
    },
  })
}

// 为每个目标元素绑定鼠标事件
targets.forEach((target) => {
  if (!target['isBindMoveEvent']) {
    target['isBindMoveEvent'] = true
    target.addEventListener('mousedown', (e) => {
      window.focus()
      e.stopPropagation()
      this.clearAllSelections()
      container.moveable.target = target
      this.currentMoveable = container.moveable
      this.handleSelectTarget?.(target, container.moveable)
      container.moveable.dragStart(e)
    })
  }
})

// 处理大小调整
container.moveable.on('resize', ({ target, width, height, drag }) => {
  target.style.width = `${width}px`
  target.style.height = `${height}px`
  target.style.left = `${drag.left}px`
  target.style.top = `${drag.top}px`
  const child = target.firstChild as HTMLDivElement
  child.style.width = `${width}px`
  child.style.height = `${height}px`
  child.style.left = `${drag.left}px`
  child.style.top = `${drag.top}px`
})

// 大小调整结束
container.moveable.on('resizeEnd', ({ target }) => {
  this.updateOriginalData('resize', target)
})

// 拖拽开始
container.moveable.on('dragStart', (event) => {
  const { target } = event
  recordOffsetStart(target as HTMLDivElement)
})

// 拖拽过程
container.moveable.on('drag', (event) => {
  const { target, left, top } = event
  // 浮动元素不允许拖拽
  if (target.classList.contains('fixed-container')) {
    return
  }
  target.style.left = `${left}px`
  target.style.top = `${top}px`
  target.\_hasMoved = true
})

// 拖拽结束
container.moveable.on('dragEnd', (event) => {
  const { target } = event
  if (target.\_hasMoved) {
    target.\_hasMoved = false
    this.updateOriginalData('drag', target as HTMLDivElement)
  }
})

2.5 位置偏移量计算

位置偏移量的计算是实现精确拖拽的关键:

typescript
// 记录拖拽开始时的位置
export const recordOffsetStart = (target: HTMLElement) => {
  const rect = target.getBoundingClientRect();
  target.$offsetStart = {
    left: rect.left,
    top: rect.top,
  };
};

// 计算拖拽的偏移量
export const getTargetOffset = (target: HTMLElement) => {
  const rect = target.getBoundingClientRect();
  const offsetStart = target.$offsetStart;
  return {
    left: rect.left - offsetStart.left,
    top: rect.top - offsetStart.top,
  };
};

2.6 根容器样式计算

根容器的高度根据内部元素自动调整:

typescript
export const getAbsoluteRootStyle = (options: {
  targetClassName: string;
  rootClassName: string;
  fixedClassName: string;
}) => {
  const { targetClassName, rootClassName } = options;
  let rootHeight = 0;
  const root = document.querySelector<HTMLDivElement>(`${rootClassName}`);

  if (!root) return null;

  const allElements = document.querySelectorAll<HTMLDivElement>(
    `${targetClassName}`
  );

  let rootPaddingBottom = root.style.paddingBottom
    ? Number.parseFloat(root.style.paddingBottom)
    : 0;
  let rootPaddingTop = root.style.paddingTop
    ? Number.parseFloat(root.style.paddingTop)
    : 0;

  if (allElements.length > 0) {
    allElements.forEach((element) => {
      const { height } = element.getBoundingClientRect();
      const elementTop = element.style.top
        ? Number.parseFloat(element.style.top)
        : 0;
      if (elementTop + height > rootHeight) {
        rootHeight = Math.max(
          elementTop + height,
          document.documentElement.clientHeight -
            rootPaddingBottom -
            rootPaddingTop
        );
      }
    });
  }

  root.style.height = `${rootHeight}px`;

  return {
    height: rootHeight + "px",
  };
};

3 关键技术细节

3.1 选择器策略

Moveable 使用精细的 CSS 选择器策略来识别和操作元素:

  • 使用:scope 选择器:限定查询范围
  • 分层选择:根据元素层级关系精确查找目标元素
  • 类名标识:通过类名区分不同功能的元素

3.2 性能优化

  • 事件委托:减少事件监听器数量
  • 防抖处理:对滚动事件进行防抖处理
  • 按需初始化:只对可见元素初始化 Moveable 实例
typescript
debouncedScrollEnd = \_debounce(this.handleScrollEnd, 100).bind(this)

3.3 组件通信机制

Moveable 通过回调函数向上传递交互事件:

typescript
updateOriginalData(type: string, target: HTMLDivElement) {
  let result = { type }
  switch (type) {
    case 'resize':
    case 'drag':
      const targetStyle = getTargetStyle(target)
      const moveableTargetId = this.getTargetId(target)
      const offset = getTargetOffset(target)

      Object.assign(result, {
        targetId: moveableTargetId,
        offset,
        style: targetStyle,
        rootStyle: this.getRootStyle(),
      })
      break;
  }
  // 通过回调函数向上传递事件
  this.onUpdateCallback?.({
    ...result,
})
}

4 交互体验优化

4.1 对齐辅助线

Moveable 提供了丰富的对齐辅助线功能:

  • 水平和垂直辅助线
  • 元素对齐辅助线
  • 吸附功能
typescript
container.moveable = new Moveable(container, {
  // 其他配置...
  snappable: true, // 启用吸附
  elementGuidelines: targets, // 元素对齐辅助线
  horizontalGuidelines: [0, container.offsetHeight / 2, container.offsetHeight],
  verticalGuidelines: [0, container.offsetWidth / 2, container.offsetWidth],
  snapThreshold: 5, // 吸附阈值
});

4.2 尺寸显示

添加了 rpx 单位的显示,便于开发者了解实际尺寸:

typescript
container.moveable.snapDistFormat = (v, type) => `${v * 2}rpx`;

总结

Moveable 模块通过对开源 Moveable 库的封装和扩展,结合 Studio 平台特性,实现了一个功能强大、易于集成的拖拽交互系统,为低代码平台提供了核心的可视化编辑能力。

基于 MIT 许可发布