Figma數值輸入框支援拖拽調整功能實現

Cmen發表於2024-07-18

最近在研究Figma的一些功能設計, 對其中的數值輸入框可以直接滑鼠拖拽調整的這個設計印象非常深刻.
這裡用了其他網友的一張動態截圖演示一下效果.

實際這個拖拽的功能不止看到的這麼簡單, 在深度研究使用之後, 發現這個拖拽可以無限的拖動, 當滑鼠超出網頁後會自動回到另一端然後繼續拖動, 而且按住shift鍵, 可以調整單次數值變化的間隔值為10, 細節非常的豐富.

這篇文章, 我們就來嘗試實現一下這個支援拖拽調整數值的輸入框元件.
實現基於: typescript + react + tailwindcss + shadcn-ui
實現的功能有:

  1. 元件支援自定義Label, 滑鼠懸浮Label拖拽調整輸入框的值
  2. 無限拖拽, 滑鼠超出網頁邊界後自動從另一邊出現
  3. 支援自定義縮放係數: 比如滑鼠拖拽1px增加多少值, 縮放係數越大, 拖動單位畫素增加的值越多
  4. 支援自定義調整間隔: 最終計算的值為間隔的整數倍
    下面是已經實現好的效果 Figma-draggable-input

下面拆解一下元件的實現邏輯

簡單的拖動更新數值實現

通常的拖動更新數值, 實現是現在元素上監聽 mousedown, 然後在document上監聽 mousemove 和 mouseup.
在 mousemove 中處理位置的計算更新邏輯, 參考draggable-input-v1.
首先, 構建state記錄的輸入框值和滑鼠的位置,

const [snapshot, setSnapshot] = useState(value);
const [mousePos, setMousePos] = useState<number[] | null>(null);

當滑鼠在左側標籤按下的時候, 記錄滑鼠的初始位置

 const onDragStart = useCallback(
    (position: number[]) => {
      setMousePos(position);
    },
    []
  );

給label的jsx繫結mousedown事件

return (
    <div
      onMouseDown={(e) => {
        onDragStart([e.clientX, e.clientY]);
      }}
      className="cursor-ew-resize absolute top-0 left-0 h-full flex items-center"
    >
      {label}
    </div>
  );

然後在document上監聽, mousemove和mouseup事件, 用於拖拽更新數值

useEffect(() => {
    // Only change the value if the drag was actually started.
    const onUpdate = (event: MouseEvent) => {
      const { clientX } = event;
      if (mousePos) {
        const newSnapshot = snapshot + clientX - mousePos[0];
        onChange(newSnapshot);
      }
    };

    // Stop the drag operation now.
    const onEnd = (event: MouseEvent) => {
      const { clientX } = event;
      if (mousePos) {
        const newSnapshot = snapshot + clientX - mousePos[0];
        setSnapshot(newSnapshot);
        setMousePos(null);
        onChange(newSnapshot);
      }
    };

    document.addEventListener('mousemove', onUpdate);
    document.addEventListener('mouseup', onEnd);
    return () => {
      document.removeEventListener('mousemove', onUpdate);
      document.removeEventListener('mouseup', onEnd);
    };
  }, [mousePos, onChange, snapshot]);

這個時候, 我們已經可以透過滑鼠拖拽來調整數值了.

但是, 和Figma的不太一樣:

  1. 沒辦法固定滑鼠樣式, 在滑鼠懸浮到其他的元素上, 樣式會根據被懸浮元素的樣式展示
  2. 滑鼠的位置沒有限制, Figma的效果是滑鼠拖拽在超出螢幕空間後從另一側出現, 而我們目前的實現方式沒辦法動態修改滑鼠的位置, 因為mosueEvent.clientX是隻讀屬性.

無限拖動的調整數值實現

基於這些問題, 可以猜測, Figma的這種無限拖拽, 其實是隱藏了滑鼠後, 用虛擬的游標模擬滑鼠操作的.
我們仔細留意一下Figma的輸入框拖拽按下的瞬間, 發現滑鼠樣式是有變化的, 進一步證明我們的猜想.

那麼下面就是考慮, 如果用JS隱藏滑鼠, 並且保證滑鼠不會移出頁面可見區域視窗.
這讓我想到了, 在瀏覽一下Web3D效果的頁面時, 滑鼠點選後進入場景互動, 此時滑鼠用於控制第一人稱視角相機, 不論怎麼拖動都不會跑到別的螢幕上.

於是問了一下Claude, 確實有這樣的一個API, Element.requestPointerLock()
可以讓我們鎖定滑鼠在某個元素內, 預設使用esc可以推出鎖定, 也可以用 document.exitPointerLock() 手動退出鎖定.
那麼我們要做的就是在滑鼠按下(mousedown)的時候, 進入鎖定, 滑鼠抬起(mouseup)的時候退出鎖定.
參考案例裡面, v2版本中draggable-label.tsx檔案中的的部分程式碼

const onDragStart = useCallback(
  (position: number[]) => {
    document.body.requestPointerLock();
    setMousePos(position);
  },
  []
);

const onEnd = () => {
  setMousePos(null);
  setCursorPosition(null);
  document.exitPointerLock();
};

解決了鎖定滑鼠的問題, 下一步就是虛擬游標模擬滑鼠移動的實現.
這一步不算複雜, 我們找一個水平resize的游標對應的svg, 在需要的時候, 控制他顯示然後調整位置即可.
這裡我直接封裝了一個hooks, 參考 use-ew-resize-cursor.tsx 的實現

import { useEffect, useState } from 'react';

const EWResizeCursorID = 'ZMeta_ew_resize_cursor';

export const useEWResizeCursor = () => {
  const [position, setPosition] = useState<number[] | null>(null);

  useEffect(() => {
    let ewCursorEle = document.querySelector(
      `#${EWResizeCursorID}`
    ) as HTMLDivElement;
    if (position == null) {
      ewCursorEle && document.body.removeChild(ewCursorEle);
      return;
    }

    if (ewCursorEle == null) {
      ewCursorEle = document.createElement('div');
      ewCursorEle.id = EWResizeCursorID;
      ewCursorEle.style.cssText =
        'position: fixed; top: 0; left: 0;transform: translate3d(-50%, -50%, 0);';
      ewCursorEle.innerHTML = `<svg t="1721283130691" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4450" width="32" height="32"><path d="M955.976575 533.675016l-166.175122 166.644747a28.148621 28.148621 0 0 1-39.845904 0c-10.945883-11.018133-10.945883-28.900021 0-39.954279l119.465463-119.826713H160.575743l119.465462 119.826713c11.018133 11.018133 11.018133 28.900021 0 39.954279a28.148621 28.148621 0 0 1-39.845904 0l-166.102872-166.644747c-5.888379-5.852254-8.381006-13.691385-8.019756-21.422141-0.36125-7.658506 2.131377-15.461511 8.019756-21.34989l166.102872-166.608622a28.148621 28.148621 0 0 1 39.845904 0c11.018133 11.018133 11.018133 28.900021 0 39.954279L160.575743 484.075355h708.845269l-119.465463-119.826713c-10.945883-11.018133-10.945883-28.900021 0-39.954279a28.148621 28.148621 0 0 1 39.845904 0l166.175122 166.608622c5.888379 5.888379 8.381006 13.691385 7.911381 21.34989 0.4335 7.730756-2.059127 15.569886-7.911381 21.422141z" fill="#bfbfbf" p-id="4451"></path></svg>`;
      document.body.appendChild(ewCursorEle);
    }

    const [x, y] = position;
    ewCursorEle.style.top = y + 'px';
    ewCursorEle.style.left = x + 'px';
  }, [position]);

  return { setPosition };
};

實現的內容很簡單, 當傳入位置時, 手動構建一個svg的游標在body下, 然後動態設定top和left的值.
那麼在mousemove 的時候, 獲取滑鼠的位移,然後更新給 useEWResizeCursor 即可.
參考我的實現是, mousedown的時候記錄初始位置, 直接展示虛擬游標並更新位置.


const { setPosition: setCursorPosition } = useEWResizeCursor();

const [mousePos, setMousePos] = useState<number[] | null>(null);

// Start the drag to change operation when the mouse button is down.
const onDragStart = useCallback(
  (position: number[]) => {
    document.body.requestPointerLock();
    setMousePos(position);
    setSnapshot(value);
    setCursorPosition(position);
  },
  [setCursorPosition, value]
);

同時記錄 mousePos 即滑鼠的實際位置, 在mousemove的時候繼續使用.
這麼做是因為, 當我們使用 requestPointerLock()之後, 滑鼠的位置已經被鎖定了, 我們在拖動滑鼠時, mouseEvent.clientX等屬性不會更新, 但是 mouseEvent.movementX 和 mosueEvent.movementY還是可以正常使用的.
因此我們需要自己記錄並計算滑鼠拖動的大概位置

const onUpdate = (event: MouseEvent) => {
    const { movementX, movementY } = event;
  if (mousePos) {
    const newSnapshot = snapshot + movementX;
    const [x, y] = mousePos;

    const newMousePos = [x + movementX, y + movementY];

    setMousePos(newMousePos);
    setCursorPosition(newMousePos);
    setSnapshot(newSnapshot);
   
    onChange(newSnapshot);
  }
};

這樣, 滑鼠拖動, 虛擬的滑鼠位置會更新, 然後輸入框的值也會更新.
但是還不能做到無限拖動, Figma的效果是, 當滑鼠水平超出網頁邊界後, 會從另一端出來, 這樣就可以週而復始的拖動.

那我們要做的就是限制虛擬的游標位置在一個範圍內, 這一步其實也很簡單, 我們只需要保證 mousePosX 在 (0, bodyWidth)之間即可.
我們可以封裝一個小的方法, 來實現這個限制的功能

function calcAbsoluteRemainder(value: number, max: number) {
  value = value % max;
  return value < 0 ? value + max : value;
}

這樣, 當滑鼠的x位置超出螢幕右側, 則會自動跳到左側, 反之亦然.
那麼我們的程式碼就可以簡單的改動一下

const onUpdate = (event: MouseEvent) => {
    const { movementX, movementY } = event;
    if (mousePos) {
      const newSnapshot = snapshot + movementX;
      const [x, y] = mousePos;

      const bodyWidth = document.documentElement.clientWidth;
      const bodyHeight = document.documentElement.clientHeight;
      const newX = calcAbsoluteRemainder(x + movementX, bodyWidth);
      const newY = calcAbsoluteRemainder(y + movementY, bodyHeight);

      const newMousePos = [newX, newY];

      setMousePos(newMousePos);
      setCursorPosition(newMousePos);
      setSnapshot(newSnapshot);
     
      onChange(newSnapshot);
    }
  };

這樣就可以做到無限拖拽修改數值了.
現在的邏輯是, 每移動一個畫素, 數值就加減1, 如果覺得這個更新的頻率太快, 希望做到 每10個畫素加減1, 我們可以再 補充一個 scale的引數, 在計算snapshot的時候, 用movementX * scale, 然後onchange的時候取整即可.

const newSnapshot = snapshot + movementX * scale;
onChange(Math.round(snapshot));

同樣, 我想每次調整的間隔是10而不是1 (Figma安裝shift拖動)
那麼可以再定義一個step引數, 在onchange的時候, 對其做整數倍計算

const newValue = Math.round(newSnapshot);
onChange(step === 1 ? newValue : Math.floor(newValue / step) * step);

這裡step為1的時候, 就直接返回取整後的值即可.

至此, 我們仿照Figma的拖拽功能已經全部實現, 看下最終效果, 簡直一模一樣.

這裡我們注意到一個小細節, 即拖拽鬆開的時候, 滑鼠是會回到起始按下的位置, 這點和figma一樣.
而且, 第一次按下的時候, 因為鎖定滑鼠, 瀏覽器預設會提示 按住esc顯示滑鼠.
看了一下Figma的web端, 也是一樣的.

相關文章