最近再研究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. kubernetes的三种探针startupprobe,ReadinessProbe,LivenessProbe记录

    kubernetes的三种探针 startupprobe: k8s1.16版本后新加的探测方式,用于判断容器内应用程序是否已经启动,如果配置了startuprobe,就会先禁用其他的探测,直到它成功为 ...

  2. Mysql 5.7 及以上版本修改密码

    登录数据后.选择 mysql 数据库 use mysql; 修改密码 update user set authentication_string=PASSWORD("mynewpasswor ...

  3. react的类组件的ts写法

    react的类组件的ts写法,声明的变量,props和state的写法 import React, { PureComponent } from 'react'; interface Iprops { ...

  4. ElasticSearch服务Java内存异常分析和排查解决

    ElasticSearch服务Java内存异常分析和排查解决 1.ElasticSearch业务微服务日志排查java.lang.IllegalStateException: Request cann ...

  5. Interceptor拦截器demo

    Interceptor拦截器demo ##接口测试类 @RestController public class TestController { @RequestMapping(value = &qu ...

  6. xxlJob需要拆分开来,不用公用同一个jobHandler

    xxlJob需要拆分开来,不用公用同一个jobHandler 不能使用同一个jobHandler,通过使用不同的任务参数来定义两个不同的job,实际在xxlJob中使用jobHandler来注册的.解 ...

  7. JSR303数据校验使用方法记录

    JSR303并不对应着指定的jar包,而是一种规范,目前hibernate-validator是使用最多的是基于JSR303规范的实现 本文不适合新人观看,要求至少要知道使用方法 Springboot ...

  8. Imdeploy笔记

    Smiling & Weeping ---- 天气不好的时候,我会小心地把自己心上的裂缝补起来.为什么?... LMDeploy 的量化和部署 1 环境配置 2 服务部署 2.1 模型转换 2 ...

  9. CSS 属性计算

    CSS 属性计算过程 你是否了解 CSS 的属性计算过程呢? 有的同学可能会讲,CSS属性我倒是知道,例如: p{ color : red; } 上面的 CSS 代码中,p 是元素选择器,color ...

  10. JS弱类型语言的优势——之模板字符串

    ES6中,开始支持模板字符串. 尽管Java和C#这样的高级语言有非常多吸引人的地方,但是想js这样的弱类型语言,也有独到之处. equType:有四种类型,分别是:chl.chwp.cwp.cot, ...