最近再研究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端, 也是一样的.

Figma数值输入框支持拖拽调整功能实现的更多相关文章

  1. 两种为wangEditor添加拖拽调整高度的方式:CSS3和jQuery UI

    wangEditor是一款优秀的Web富文本编辑器,但如果能像KindEditor那样支持拖拽调整高度就更好了.有两种方式可以为wangEditor添加这一功能,这里使用的wangEditor版本为2 ...

  2. GMF Q&A(1): 如何让palette支持拖拽(DnD)等10则

    1,如何让palette支持拖拽(DnD) 在*PaletteFactory类中,把私有类NodeToolEntry 和LinkToolEntry的基类修改为PaletteToolEntry.并在构造 ...

  3. 一个可以自由定制外观、支持拖拽消除的MaterialDesign风格Android BadgeView

    为了尊重作者,先放上链接:https://github.com/qstumn/BadgeView BadgeView 一个可以自由定制外观.支持拖拽消除的MaterialDesign风格Android ...

  4. Qt无边框窗体-最大化时支持拖拽还原

    目录 一.概述 二.效果展示 三.demo制作 1.设计窗体 2.双击放大 四.拖拽 五.相关文章 原文链接:Markdown模板 一.概述 用Qt进行开发界面时,既想要实现友好的用户交互又想界面漂亮 ...

  5. .net mvc mssql easyui treegrid 及时 编辑 ,支持拖拽

    这里提到了,1个问题,怎么扩展 Easyui 参见: http://blog.csdn.net/chenkai6529/article/details/17528833 @{ ViewBag.Titl ...

  6. angular-dragon-drop.js 双向数据绑定拖拽的功能

    在做公司后台物流的时候,涉及到34个省市分为两个部分,一部分为配送区域,另一部分为非配送区域,想利用拖拽的功能来实现,最好两部分的数组能自动更新. 刚好找到angular-dragon-drop.js ...

  7. 关于安装了VMware tools后仍然不支持拖拽文件的问题

    我在学校机房里面的redhat4上面安装了VMware tools之后能正常支持拖拽,但是我自己电脑上的却不支持,折腾了好久,网上找了很久也还是没有解决,不过发现了一些问题,总结如下:(当然我总结的这 ...

  8. RecyclerViewItemTouchHelperDemo【使用ItemTouchHelper进行拖拽排序功能】

    版权声明:本文为HaiyuKing原创文章,转载请注明出处! 前言 记录使用ItemTouchHelper对Recyclerview进行拖拽排序功能的实现. 效果图 代码分析 ItemTouchHel ...

  9. 让一个view 或者控件不支持拖拽

    让一个view 或者控件不支持拖拽: dragView.userInteractionEnabled = NO;

  10. jQuery插件之路(三)——文件上传(支持拖拽上传)

    好了,这次咱一改往日的作风,就不多说废话了,哈哈.先贴上源代码地址,点击获取.然后直接进入主题啦,当然,如果你觉得我有哪里写的不对或者欠妥的地方,欢迎留言指出.在附上一些代码之前,我们还是先来了解下, ...

随机推荐

  1. 鸿蒙HarmonyOS实战-Stage模型(信息传递载体Want)

    前言 应用中的信息传递是为了实现各种功能和交互.信息传递可以帮助用户和应用之间进行有效的沟通和交流.通过信息传递,应用可以向用户传递重要的消息.通知和提示,以提供及时的反馈和指导.同时,用户也可以通过 ...

  2. Java 8 中Stream用法

    Stream是Java 8新增的接口,Stream可以认为是一个高级版本的 Iterator. 废话不多说直接上代码 package com.example.demo; import org.juni ...

  3. jquery 给表格添加或删除一行

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  4. 8.10考试总结(NOIP模拟35)[玩游戏·排列·最短路·矩形]

    所谓人,无论是谁到了最后,都会形单影只. T1 玩游戏 解题思路 可以把序列从 k 位置掰成两个序列. 问题就变成了两个序列从开头走向末尾是否可以保证前缀和之和一直不大于 0 . 并且可以移动到两个序 ...

  5. 面试官:说一说如何优雅的关闭线程池,我:shutdownNow,面试官:粗鲁!

    写在开头 面试官:"小伙子,线程池使用过吗,来聊一聊它吧!" 我:"好的,然后巴拉巴拉一顿输出之前看过的build哥线程池十八问..." 面试官满意的点了点头, ...

  6. 基于Vue的二进制时钟组件 -- fx67llBinaryClock

    fx67llClock Easy & Good Clock ! npm 组件说明 一个基于Vue的二进制时钟组件,没什么卵用,做着好玩,可以方便您装饰个人主页 使用步骤 npm install ...

  7. 小程序的文件结构及配置 小程序配置 app.json

    程序包含一个描述整体程序的 app 和多个描述各自页面的 page. 一个小程序主体部分由三个文件组成,必须放在项目的根目录,如下: 文件 必填 作用 app.js 是 小程序逻辑-小程序入口文件 a ...

  8. vue Ref 动态组件 keeplive

    ref被用来给元素或子组件注册引用信息.引用信息将会注册在父组件的 $refs 对象上.如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素:如果用在子组件上,引用就指向组件实例 # 普通d ...

  9. nfs笔记整理

    NFS---共享存储系统 #network file system 网络文件系统 ​ #NFS主要使用在局域网下,让不同的主机之间可以共享文件.或者目录数据.主要用于linux系统上实现文件共享的一种 ...

  10. Java中的Collection集合(单列集合)

    1.集合概述 集合:集合是java中提供的一种容器,可以用来存储多个数据. 集合与数组的区别: (1)数组的长度是固定的,集合的长度是可变的. (2)数组中存储的是同一类型的元素,可以存储基本数据类型 ...