jtwang7 / JavaScript-Note

JavaScript学习笔记
10 stars 2 forks source link

实现元素拖拽修改宽高和位移 #68

Open jtwang7 opened 2 years ago

jtwang7 commented 2 years ago

实现元素拖拽修改宽高和位移

参考文章:追求完美代码之——实现元素拖拽修改宽高和位移插件

❇️ 拖拽修改的基本流程

❇️ 拖拽实现

❎ 错误实现

存在的问题: 由于 mouse 事件是绑定在元素本身,mousedownmousemovemouseup 都要求在元素上触发,这就导致假如鼠标移动过快时,元素还没有移动,鼠标就已经离开元素了,导致 mousemovemouseup 无法有效触发,最终元素移动会显得卡顿,甚至会导致事件绑定无法及时清除。

✅ 正确实现

给顶部节点(如document)加上事件绑定,然后通过事件代理来实现拖拽元素准确定位:

// 拖拽元素dom
const element = document.querySelector("div");

// 顶层节点绑定事件监听
document.addEventListener("mousedown", (e) => {
  // 过滤掉非目标元素
  if (e.target !== element) {
    return;
  }
  let x0 = e.clientX;
  let y0 = e.clientY;
  const handleMove = ({ clientX, clientY, target }) => {
    element.style.left = `${parseFloat(element.style.left, 10) + clientX - x0}px`;
    element.style.top = `${parseFloat(element.style.top, 10) + clientY - y0}px`;
    x0 = clientX;
    y0 = clientY;
  };
  document.addEventListener("mousemove", handleMove);
  const fn = () => {
    document.removeEventListener("mousemove", handleMove);
    document.removeEventListener("mouseup", fn);
  }
  document.addEventListener("mouseup", fn);
});

❇️ 控件实现

❎ 难点

控件的难点在于与实际目标元素的关联要时刻保持同步,这里利用了 Proxy 代理控件元素的样式修改,从而实现了修改控件元素的同时,修改目标元素的样式。

💡Proxy 代理样式修改!

function getPxNumber(str) {
  return parseFloat(str, 10);
}

// 目标元素dom
const element = document.querySelector("div");
// 获取目标元素精确的初始位置
const { x, y, width, height } = element.getBoundingClientRect();
// 创建控件容器
const controlWrapper = document.createElement("div");

// 🌟 代理控件容器的style字段
// get: 访问控件容器style内部字段时,添加默认值逻辑判别
// set: 修改控件容器样式的同时同步更改目标元素的样式
const _style_ = new Proxy(controlWrapper.style, {
  get(target, key) {
    // 获取controlWrapper.style.xxx的初始样式值
    let originalStyleValue = Reflect.get(target, key);
    // 添加默认值逻辑
    if (
      ["width", "height", "left", "top"].includes(key) &&
      !originalStyleValue
    ) {
      // dom.style.xxx 没设置过是"",所以第一次获取时,要调用其他途径获取样式
      originalStyleValue = controlWrapper.getBoundingClientRect()[key];
    }
    return originalStyleValue;
  },
  set(target, key, val) {
    const pxNumber = getPxNumber(val);
    // 修改控件容器时,同步将目标元素的style更改
    if (["width", "height", "left", "top"].includes(key)) {
      element.style[key] = val;
    }
    Reflect.set(target, key, val);
    return val;
  },
});

// 将代理对象_style_挂载到控件容器dom对象上
// 注: dom.style 不可以覆盖,因此要挂载到新的自定义声明字段 _style_
controlWrapper._style_ = _style_;

// 设置控件容器初始样式
Object.assign(controlWrapper.style, {
  position: "fixed",
  width: `${width}px`,
  height: `${height}px`,
  top: `${y}px`,
  left: `${x}px`,
  cursor: "all-scroll",
  border: "1px dashed #000",
});

✅ 控件实现

1️⃣ 创建控件元素

// 创建控件元素
function renderCorner({ width, height }) {
  // 来4个元素
  const eles = Array.from({ length: 4 }).map(() =>
    document.createElement("div")
  );
  eles.forEach((x) => x.classList.add("controller-corner"));
  // 分别在topleft、topright、bottomleft、bottomright位置
  const [tl, tr, bl, br] = eles;

  // 每一个角都移动半个身位
  Object.assign(tl.style, {
    top: `-5px`,
    left: `-5px`,
    cursor: "nw-resize",
  });
  Object.assign(tr.style, {
    top: `-5px`,
    cursor: "ne-resize",
    right: `-5px`,
  });
  Object.assign(bl.style, {
    bottom: `-5px`,
    cursor: "sw-resize",
    left: `-5px`,
  });
  Object.assign(br.style, {
    bottom: `-5px`,
    cursor: "se-resize",
    right: `-5px`,
  });
  return { eles };
}

2️⃣ 抽离公共拖拽方法

// 抽离公共拖拽方法
// onMove:处理mousemove的函数
// bindUpAndDown:用来绑定up和down事件的,作为开始和收尾
function handleMouseDown(onMove, bindUpAndDown) {
  return function ({ target, clientX: x, clientY: y }) {
    let x0 = x;
    let y0 = y;
    function handleMove(e, ...rest) {
      const { clientX, clientY } = e;
      e.preventDefault();
      const detaX = clientX - x0;
      const detaY = clientY - y0;
      x0 = clientX;
      y0 = clientY;
      // 我们前面说到,拖拽过程中,x、y坐标变化量是核心的参数
      onMove(target, detaX, detaY, ...rest);
    }
    // 透传target和handleMove,因为开始和收尾的down和up都要用到它们
    bindUpAndDown(target, handleMove);
  };
}

3️⃣ 添加功能

// 获取四个角——eles,传入的width, height是目标元素的getBoundingClientRect
const { eles } = renderCorner({ width, height });
const [tl, tr, bl, br] = eles;
// 在handleMouseDown传入onMove, bindUpAndDown
const handleControlerMouseDown = handleMouseDown(
  (target, detaX, detaY, isMoveTargetElement) => {
    // 移动的时候的处理
    // 是否是左边两个角
    const isLeft = [tl, bl].includes(target);
    // 是否是上面两个角
    const isTop = [tl, tr].includes(target);
    // 在左边,deta变化量要相反
    const directionLeft = !isLeft ? 1 : -1;
    const directionTop = !isTop ? 1 : -1;
    // 新的宽度、高度
    let newWidth = getPxNumber(ele._style_.width) + directionLeft * detaX;
    let newHeight = getPxNumber(ele._style_.height) + directionTop * detaY;

    // 区分拖动非4个角的控件的情况,此时是拖动整个元素本身
    if (isMoveTargetElement) {
      const newL = getPxNumber(ele._style_.left);
      const newT = getPxNumber(ele._style_.top);
      ele._style_.left = `${newL + detaX}px`;
      ele._style_.top = `${newT + detaY}px`;
      return;
    }

    // 拖动4个角
    ele._style_.width = `${newWidth}px`;
    ele._style_.height = `${newHeight}px`;
    // 拖左边的时候,实际上也会拖动元素本身
    ele._style_.left = isLeft
      ? `${getPxNumber(ele._style_.left) - directionLeft * detaX}px`
      : ele._style_.left;

    ele._style_.top = isTop
      ? `${getPxNumber(ele._style_.top) - directionTop * detaY}px`
      : ele._style_.top;
  },
  (target, handleMove) => {
    // 绑定事件的时候的处理
    const handleMoveTargetElement = (e) => handleMove(e, true);
    // 针对拖动4个角和非4个角的处理
    // 拖4个角改变宽高
    if (eles.includes(target)) {
      document.addEventListener("mousemove", handleMove);
    } else {
      // 拖控件非4个角的本体部分改变位置
      document.addEventListener("mousemove", handleMoveTargetElement);
    }
    document.addEventListener("mouseup", ({ target }) => {
      document.removeEventListener("mousemove", handleMove);
      document.removeEventListener("mousemove", handleMoveTargetElement);
    });
  }
);
document.addEventListener("mousedown", handleControlerMouseDown);
// 挂载元素
eles.forEach((e) => {
  ele.appendChild(e);
});

4️⃣ 支持随时移除、增加控件

// 在挂载元素后,return一个清除事件的方法
eles.forEach((e) => {
  ele.appendChild(e);
});
return {
  removeControler() {
    eles.forEach((e) => {
      ele.removeChild(e);
    });
    document.removeEventListener("mousedown", handleControlerMouseDown);
  },
  eles: [...eles, ele],
};

🔆 总入口

function injectDragger(ele) {
  let removeDragger;
  ele.addEventListener("click", () => {
    if (!removeDragger) {
      // 增加控件,然后保存暴露出来的清除方法随时使用
      const { removeAllControler, eles } = injectController(ele);
      removeDragger = removeAllControler;
      const handleRemove = ({ target }) => {
        // 监听鼠标弹起,如果不是从控件容器弹起,也就是点了其他地方,那这些控件都要删掉
        if (![...eles, ele].includes(target)) {
          removeDragger && removeDragger();
          removeDragger = undefined;
          document.removeEventListener("mouseup", handleRemove);
        }
      };
      document.addEventListener("mouseup", handleRemove);
    }
  });
}