最近再研究Figma的一些功能设计, 对其中的数值输入框可以直接鼠标拖拽的这个设计印象非常深刻.
这里用了其他网友的一张动态截图演示一下效果.
实际这个拖拽的功能不止看到的这么简单, 在深度研究使用之后, 发现这个拖拽可以无限的拖动, 当鼠标超出网页后会自动回到另一端然后继续拖动, 而且按住shift键, 可以调整单次数值变化的间隔值为10, 细节非常的丰富.
这篇文章, 我们就来尝试实现一下这个支持拖拽调整数值的输入框组件.
实现基于: typescript + react + tailwindcss + shadcn-ui
实现的功能有:
- 组件支持自定义Label, 鼠标悬浮Label拖拽调整输入框的值
- 无限拖拽, 鼠标超出网页边界后自动从另一边出现
- 支持自定义缩放系数: 比如鼠标拖拽1px增加多少值, 缩放系数越大, 拖动单位像素增加的值越多
- 支持自定义调整间隔: 最终计算的值为间隔的整数倍
下面是已经实现好的效果 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的不太一样:
- 没办法固定鼠标样式, 在鼠标悬浮到其他的元素上, 样式会根据被悬浮元素的样式展示
- 鼠标的位置没有限制, 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端, 也是一样的.