Figma数值输入框支持拖拽调整功能实现

最近再研究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记录的输入框值和鼠标的位置,

ts 复制代码
const [snapshot, setSnapshot] = useState(value);
const [mousePos, setMousePos] = useState<number[] | null>(null);

当鼠标在左侧标签按下的时候, 记录鼠标的初始位置

ts 复制代码
 const onDragStart = useCallback(
    (position: number[]) => {
      setMousePos(position);
    },
    []
  );

给label的jsx绑定mousedown事件

tsx 复制代码
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事件, 用于拖拽更新数值

ts 复制代码
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文件中的的部分代码

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 的实现

ts 复制代码
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的时候记录初始位置, 直接展示虚拟光标并更新位置.

ts 复制代码
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还是可以正常使用的.

因此我们需要自己记录并计算鼠标拖动的大概位置

ts 复制代码
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)之间即可.

我们可以封装一个小的方法, 来实现这个限制的功能

ts 复制代码
function calcAbsoluteRemainder(value: number, max: number) {
  value = value % max;
  return value < 0 ? value + max : value;
}

这样, 当鼠标的x位置超出屏幕右侧, 则会自动跳到左侧, 反之亦然.

那么我们的代码就可以简单的改动一下

ts 复制代码
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的时候取整即可.

ts 复制代码
const newSnapshot = snapshot + movementX * scale;
onChange(Math.round(snapshot));

同样, 我想每次调整的间隔是10而不是1 (Figma安装shift拖动)

那么可以再定义一个step参数, 在onchange的时候, 对其做整数倍计算

ts 复制代码
const newValue = Math.round(newSnapshot);
onChange(step === 1 ? newValue : Math.floor(newValue / step) * step);

这里step为1的时候, 就直接返回取整后的值即可.

至此, 我们仿照Figma的拖拽功能已经全部实现, 看下最终效果, 简直一模一样.

这里我们注意到一个小细节, 即拖拽松开的时候, 鼠标是会回到起始按下的位置, 这点和figma一样.

而且, 第一次按下的时候, 因为锁定鼠标, 浏览器默认会提示 按住esc显示鼠标.

看了一下Figma的web端, 也是一样的.