追求完美代碼之——實現元素拖拽修改寬高和位移插件

前言

咱們若是使用過ppt、keynote,元素的小控件必定少不了,能夠實現修改修改寬高和位移,大概是這樣css

最終效果預覽:html

下面,咱們從0開始,使用原生js實現這個效果,並封裝成插件前端

過程分析

  • 一個元素正常展現。點擊的時候,會多出邊框,邊框的角落會有拖拽修改寬高的控件,控件位置、大小和元素如出一轍
  • 點擊某個角落的拖拽控件,以該控件的的中心對稱點爲中心點,變動寬高。新的width = 舊的width + 控件x座標變化量(可正可負),height也是
  • 點擊非某個角落的拖拽控件的拖拽控件,拖拽整個元素,此時cursorall-scroll
  • 點擊其餘地方,控件消失,元素變成本來樣子

  • 代碼複用:多處涉及到拖拽,拖拽須要抽取出來作公共方法

實現一個拖拽

❌ 錯誤示範canvas

元素加上mousedown(按下的時候)事件,此時開始綁定mousemove;當鼠標彈起,移除mousemove事件綁定。也就是鼠標在元素上按下的時候,每次move都移動元素,鼠標彈起的時候,清除事件綁定app

mousemove事件觸發的時候,計算本次位置和上次位置x、y座標(即left、top)差值,並加上left、top位置,便可得到拖動後的新位置dom

// html只有一個div,而且有設置position
    const ele = document.querySelector("div");
    ele.addEventListener("mousedown", e => {
    // 記錄首次位置,也是爲了存放上次位置
      let x0 = e.clientX;
      let y0 = e.clientY;
      const handleMove = ({ clientX, clientY, target }) => {
      // 本次位置和上次位置的變化量
        ele.style.left = `${parseFloat(ele.style.left, 10) + clientX - x0}px`;
        ele.style.top = `${parseFloat(ele.style.top, 10) + clientY - y0}px`;
          // 上次位置更新
        x0 = clientX;
        y0 = clientY;
      };
      ele.addEventListener("mousemove", handleMove);
      ele.addEventListener("mouseup", () => {
        ele.removeEventListener("mousemove", handleMove);
      });
    });
複製代碼

慢慢拖、慢慢拖,很okide

可是......試試快速拖動會發生什麼事情,是否是有一種手滑的效果?而後元素跟丟了。若是你的div很大,跟丟的機率會小不少函數

✅ 正確的作法學習

給頂部節點(如document)加上事件綁定,而後經過事件代理來實現拖拽元素準肯定位:ui

const ele = document.querySelector("div");
    // 換成document
    document.addEventListener("mousedown", e => {
    // 這裏過濾掉非目標元素
      if (e.target !== ele) {
        return;
      }
      let x0 = e.clientX;
      let y0 = e.clientY;
      const handleMove = ({ clientX, clientY, target }) => {
        ele.style.left = `${parseFloat(ele.style.left, 10) + clientX - x0}px`;
        ele.style.top = `${parseFloat(ele.style.top, 10) + clientY - y0}px`;
        x0 = clientX;
        y0 = clientY;
      };
      document.addEventListener("mousemove", handleMove);
      document.addEventListener("mouseup", () => {
        document.removeEventListener("mousemove", handleMove);
      });
    });
複製代碼

canvas寫字其實也是一樣的道理,按下後的移動單位時間元的變化量加到目標元素上。都是利用了x、y座標變化量,只是move處理的時候是用畫canvas替代了修改html元素樣式

增長控件

  • 控件容器定位準確:控件必定要和元素徹底同樣的定位,因此使用getBoundingClientRect計算初始位置,後面使用fixed定位來維護
  • 控件容器內小控件使用絕對定位,保證控件是在控件容器固定位置
  • 鼠標指針修改:不一樣的位置有相應的方向的cursor,追求更好的用戶體驗
  • 目標元素最好是fixed定位,能夠忽略層級、嵌套,直接以根節點爲基準來維護座標

基本樣式和定位

function getPxNumber(str) {
        return parseFloat(str, 10);
      }
    
      const ele = document.querySelector("div");
      // 獲取目標元素精確的初始位置
      const { x, y, width, height } = ele.getBoundingClientRect();
      // 控件容器
      const controlWrapper = document.createElement("div");
      // 掛一個數據代理,設置代理對象的時候同時設置目標元素和控件容器樣式
      const _style_ = new Proxy(controlWrapper.style, {
        get(o, key) {
          // 獲取controlWrapper.style.xxx的xxx樣式值
          let originalStyleValue = Reflect.get(o, key);
          // 接下來咱們改的是這4個key
          if (
            ["width", "height", "left", "top"].includes(key) &&
            !originalStyleValue
          ) {
            // dom.style.xxx 沒設置過是"",因此第一次要這樣獲取
            originalStyleValue = controlWrapper.getBoundingClientRect()[key];
          }
          return originalStyleValue;
        },
        set(o, key, val) {
          // 好比獲取"16.6px"的16.6數字
          const pxNumber = getPxNumber(val);
          // 咱們改的是這4個key
          // 改控件容器的時候,順便把目標元素的style也改一下
          if (["width", "height", "left", "top"].includes(key)) {
            ele.style[key] = val;
          }
          Reflect.set(o, key, val);
          return val;
        }
      });
      // 設置控件容器初始樣式
      Object.assign(controlWrapper.style, {
        position: "fixed",
        width: `${width}px`,
        height: `${height}px`,
        top: `${y}px`,
        left: `${x}px`,
        // 拖拽整個元素移動的時候,是"all-scroll"光標
        cursor: "all-scroll", 
        border: "1px dashed #000"
      });
      // 代理_style_掛在controlWrapper上
      controlWrapper._style_ = _style_;

複製代碼

此時,咱們已經有控件容器了,加上虛線,方便辨識

接着,咱們須要把四個角的控件加上,拖拽一個角控制寬高的:

它們的樣式先來一個

.controller-corner {
        width: 10px;
        height: 10px;
        background-color: #faa;
        position: absolute;
      }
複製代碼

這是一個建立4個控件元素的方法,這個函數返回這4個元素供外部使用

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 };
    }
複製代碼

添加拖拽事件與功能邏輯

  • 拖拽四個角,改變元素寬高。拖右邊兩個角,只改變寬高,寬高改變量和新的寬高是正相關的;拖左邊兩個角,除了寬高還要改變top、left,並且寬高改變量和新的寬高是負相關的

  • 拖拽target是控件容器裏面非四個角,改變元素位置。這個狀況比較簡單了,直接用x、y座標變化量加上本來位置便可

屢次涉及到拖拽,先實現一個公共的處理方法:

// 拖拽的套路修改一下
// 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);
      };
    }
複製代碼

添加功能邏輯

// 獲取四個角——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);
      });
複製代碼

支持隨時移除、增長控件

有了新增事件監聽,那也很天然要有刪除事件監聽的方法。如何設計最簡單呢,固然是萬能的return一個新函數大法:

// 在掛載元素後,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);
        }
      });
    }
複製代碼

解決body自帶margin錯位問題

由於頁面默認body有8個margin,若是不處理,那麼前面這套在使用的時候,getBoundingClientRect和fixed定位不會徹底對齊,形成每次編輯有8個px差錯。

因此,咱們在最開始的ele.getBoundingClientRect那一步開始,要加上margin

const { x, y, width, height } = ele.getBoundingClientRect();
      // 獲取body自帶的margin
      const bodyMargin = getPxNumber(getComputedStyle(document.body).margin);
      const controlWrapper = document.createElement("div");
      const _style_ = new Proxy(controlWrapper.style, {
        get(o, key) {
          let originalStyleValue = Reflect.get(o, key);
          if (
            ["width", "height", "left", "top"].includes(key) &&
            !originalStyleValue
          ) {
            originalStyleValue = controlWrapper.getBoundingClientRect()[key];
          }
          return originalStyleValue;
        },
        set(o, key, val) {
          const pxNumber = getPxNumber(val);
          // 設置位置的時候,須要去掉自帶的margin影響
          if (["left", "top"].includes(key)) {
            ele.style[key] = `${pxNumber - bodyMargin}px`;
          } else if (["width", "height"].includes(key)) {
            ele.style[key] = val;
          }
          Reflect.set(o, key, val);
          return val;
        }
      });
複製代碼

同時支持pc、移動端

上面代碼全是pc的鼠標事件,移動端加不能用了,固然,再寫一份就能夠。做爲完美追求者,這種事情必定不會作的,咱們看看移動端touch和pc的mouse在本功能上最主要的區別:

  • pc: e.target.clientX
  • 移動端: e.target.touches[0].clientX(移動端能夠多手指觸屏,咱們這裏按照第一個手指行爲來作)

本身給原型對象掛一個新的事件綁定。寫好後,第一步是全局替換原有的名字

const MOBILE_MAP = {
      mousedown: "touchstart",
      mousemove: "touchmove",
      mouseup: "touchend"
    };
    HTMLDocument.prototype._addEventListener = function(key, cb, ...rest) {
      document.addEventListener(key, cb, ...rest);
      document.addEventListener(MOBILE_MAP[key], cb, ...rest);
    };
    HTMLDocument.prototype._removeEventListener = function(key, cb, ...rest) {
      document.removeEventListener(key, cb, ...rest);
      document.removeEventListener(MOBILE_MAP[key], cb, ...rest);
    };
複製代碼

替換名字後,在代碼中clientX、clientY要兼容雙端:

// ...
      let x0 = e.clientX || e.touches[0].clientX;
      let y0 = e.clientY || e.touches[0].clientY;
      const handleMove = ({
        touches,
        clientX = touches[0].clientX,
        clientY = touches[0].clientY,
        target
      }) => {}
// ...
複製代碼

最後

擴展:最開始的時候,傳入一個config對象,每個函數都會透傳這個對象,這個對象貫穿整個過程,控制每個流程能夠個性化配置

代碼比較多,具體代碼見codesandbox,還有旋轉功能沒有實現,其實就是擴展一下控件便可

關注公衆號《不同的前端》,以不同的視角學習前端,快速成長,一塊兒把玩最新的技術、探索各類黑科技

相關文章
相關標籤/搜索