我们如果使用过ppt、keynote,元素的小控件一定少不了,可以实现修改修改宽高和位移,大概是这样
最终效果预览:
下面,我们从0开始,使用原生js实现这个效果,并封装成插件
cursor
为all-scroll
❌ 错误示范
给元素加上mousedown(按下的时候)事件,此时开始绑定mousemove;当鼠标弹起,移除mousemove事件绑定。也就是鼠标在元素上按下的时候,每次move都移动元素,鼠标弹起的时候,清除事件绑定
mousemove事件触发的时候,计算本次位置和上次位置x、y坐标(即left、top)差值,并加上left、top位置,即可获得拖动后的新位置
// html只有一个div,并且有设置position
const ele = document.querySelector("div");
ele.addEventListener("mousedown", e => {
// 记录首次位置,也是为了存放上次位置
let x0 = e.clientX;
let y0 = e.clientY;
const handleMove = ({ clientX, clientY, target }) => {
// 本次位置和上次位置的变化量
ele.style.left = `${parseFloat(ele.style.left, 10) + clientX - x0}px`;
ele.style.top = `${parseFloat(ele.style.top, 10) + clientY - y0}px`;
// 上次位置更新
x0 = clientX;
y0 = clientY;
};
ele.addEventListener("mousemove", handleMove);
ele.addEventListener("mouseup", () => {
ele.removeEventListener("mousemove", handleMove);
});
});
复制代码
慢慢拖、慢慢拖,很ok
但是......试试快速拖动会发生什么事情,是不是有一种手滑的效果?然后元素跟丢了。如果你的div很大,跟丢的概率会小很多
✅ 正确的做法
给顶部节点(如document)加上事件绑定,然后通过事件代理来实现拖拽元素准确定位:
const ele = document.querySelector("div");
// 换成document
document.addEventListener("mousedown", e => {
// 这里过滤掉非目标元素
if (e.target !== ele) {
return;
}
let x0 = e.clientX;
let y0 = e.clientY;
const handleMove = ({ clientX, clientY, target }) => {
ele.style.left = `${parseFloat(ele.style.left, 10) + clientX - x0}px`;
ele.style.top = `${parseFloat(ele.style.top, 10) + clientY - y0}px`;
x0 = clientX;
y0 = clientY;
};
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", () => {
document.removeEventListener("mousemove", handleMove);
});
});
复制代码
canvas写字其实也是同样的道理,按下后的移动单位时间元的变化量加到目标元素上。都是利用了x、y坐标变化量,只是move处理的时候是用画canvas替代了修改html元素样式
function getPxNumber(str) {
return parseFloat(str, 10);
}
const ele = document.querySelector("div");
// 获取目标元素精确的初始位置
const { x, y, width, height } = ele.getBoundingClientRect();
// 控件容器
const controlWrapper = document.createElement("div");
// 挂一个数据代理,设置代理对象的时候同时设置目标元素和控件容器样式
const _style_ = new Proxy(controlWrapper.style, {
get(o, key) {
// 获取controlWrapper.style.xxx的xxx样式值
let originalStyleValue = Reflect.get(o, key);
// 接下来我们改的是这4个key
if (
["width", "height", "left", "top"].includes(key) &&
!originalStyleValue
) {
// dom.style.xxx 没设置过是"",所以第一次要这样获取
originalStyleValue = controlWrapper.getBoundingClientRect()[key];
}
return originalStyleValue;
},
set(o, key, val) {
// 比如获取"16.6px"的16.6数字
const pxNumber = getPxNumber(val);
// 我们改的是这4个key
// 改控件容器的时候,顺便把目标元素的style也改一下
if (["width", "height", "left", "top"].includes(key)) {
ele.style[key] = val;
}
Reflect.set(o, key, val);
return val;
}
});
// 设置控件容器初始样式
Object.assign(controlWrapper.style, {
position: "fixed",
width: `${width}px`,
height: `${height}px`,
top: `${y}px`,
left: `${x}px`,
// 拖拽整个元素移动的时候,是"all-scroll"光标
cursor: "all-scroll",
border: "1px dashed #000"
});
// 代理_style_挂在controlWrapper上
controlWrapper._style_ = _style_;
复制代码
此时,我们已经有控件容器了,加上虚线,方便辨识
接着,我们需要把四个角的控件加上,拖拽一个角控制宽高的:
它们的样式先来一个
.controller-corner {
width: 10px;
height: 10px;
background-color: #faa;
position: absolute;
}
这是一个创建4个控件元素的方法,这个函数返回这4个元素供外部使用
function renderCorner({ width, height }) {
// 来4个元素
const eles = Array.from({ length: 4 }).map(() =>
document.createElement("div")
);
eles.forEach(x => x.classList.add("controller-corner"));
// 分别在topleft、topright、bottomleft、bottomright位置
const [tl, tr, bl, br] = eles;
// 每一个角都移动半个身位
Object.assign(tl.style, {
top: `-5px`,
left: `-5px`,
cursor: "nw-resize"
});
Object.assign(tr.style, {
top: `-5px`,
cursor: "ne-resize",
right: `-5px`
});
Object.assign(bl.style, {
bottom: `-5px`,
cursor: "sw-resize",
left: `-5px`
});
Object.assign(br.style, {
bottom: `-5px`,
cursor: "se-resize",
right: `-5px`
});
return { eles };
}
复制代码
多次涉及到拖拽,先实现一个公共的处理方法:
// 拖拽的套路修改一下
// onMove就是处理mousemove的函数
// bindUpAndDown是用来绑定up和down事件的,作为开始和收尾
function handleMouseDown(onMove, bindUpAndDown) {
return function({ target, clientX: x, clientY: y }) {
let x0 = x;
let y0 = y;
function handleMove(e, ...rest) {
const { clientX, clientY } = e;
e.preventDefault();
const detaX = clientX - x0;
const detaY = clientY - y0;
x0 = clientX;
y0 = clientY;
// 我们前面说到,拖拽过程中,x、y坐标变化量是核心的参数
onMove(target, detaX, detaY, ...rest);
}
// 透传target和handleMove,因为开始和收尾的down和up都要用到它们
bindUpAndDown(target, handleMove);
};
}
复制代码
添加功能逻辑
// 获取四个角——eles,传入的width, height是目标元素的getBoundingClientRect
const { eles } = renderCorner({ width, height });
const [tl, tr, bl, br] = eles;
// 在handleMouseDown传入onMove, bindUpAndDown
const handleControlerMouseDown = handleMouseDown(
(target, detaX, detaY, isMoveTargetElement) => {
// 移动的时候的处理
// 是否是左边两个角
const isLeft = [tl, bl].includes(target);
// 是否是上面两个角
const isTop = [tl, tr].includes(target);
// 在左边,deta变化量要相反
const directionLeft = !isLeft ? 1 : -1;
const directionTop = !isTop ? 1 : -1;
// 新的宽度、高度
let newWidth = getPxNumber(ele._style_.width) + directionLeft * detaX;
let newHeight =
getPxNumber(ele._style_.height) + directionTop * detaY;
// 区分拖动非4个角的控件的情况,此时是拖动整个元素本身
if (isMoveTargetElement) {
const newL = getPxNumber(ele._style_.left);
const newT = getPxNumber(ele._style_.top);
ele._style_.left = `${newL + detaX}px`;
ele._style_.top = `${newT + detaY}px`;
return;
}
// 拖动4个角
ele._style_.width = `${newWidth}px`;
ele._style_.height = `${newHeight}px`;
// 拖左边的时候,实际上也会拖动元素本身
ele._style_.left = isLeft
? `${getPxNumber(ele._style_.left) - directionLeft * detaX}px`
: ele._style_.left;
ele._style_.top = isTop
? `${getPxNumber(ele._style_.top) - directionTop * detaY}px`
: ele._style_.top;
},
(target, handleMove) => {
// 绑定事件的时候的处理
const handleMoveTargetElement = e => handleMove(e, true);
// 针对拖动4个角和非4个角的处理
// 拖4个角改变宽高
if (eles.includes(target)) {
document.addEventListener("mousemove", handleMove);
} else {
// 拖控件非4个角的本体部分改变位置
document.addEventListener("mousemove", handleMoveTargetElement);
}
document.addEventListener("mouseup", ({ target }) => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mousemove", handleMoveTargetElement);
});
}
);
document.addEventListener("mousedown", handleControlerMouseDown);
// 挂载元素
eles.forEach(e => {
ele.appendChild(e);
});
复制代码
有了新增事件监听,那也很自然要有删除事件监听的方法。如何设计最简单呢,当然是万能的return一个新函数大法:
// 在挂载元素后,return一个清除事件的方法
eles.forEach(e => {
ele.appendChild(e);
});
return {
removeControler() {
eles.forEach(e => {
ele.removeChild(e);
});
document.removeEventListener("mousedown", handleControlerMouseDown);
},
eles: [...eles, ele]
};
复制代码
后面一直透传这个方法就行,给最外面那层使用。最外面那个函数,是给元素新增这些功能的总入口:
function injectDragger(ele) {
let removeDragger;
ele.addEventListener("click", () => {
if (!removeDragger) {
// 增加控件,然后保存暴露出来的清除方法随时使用
const { removeAllControler, eles } = injectController(ele);
removeDragger = removeAllControler;
const handleRemove = ({ target }) => {
// 监听鼠标弹起,如果不是从控件容器弹起,也就是点了其他地方,那这些控件都要删掉
if (![...eles, ele].includes(target)) {
removeDragger && removeDragger();
removeDragger = undefined;
document.removeEventListener("mouseup", handleRemove);
}
};
document.addEventListener("mouseup", handleRemove);
}
});
}
复制代码
因为页面默认body有8个margin,如果不处理,那么前面这套在使用的时候,getBoundingClientRect和fixed定位不会完全对齐,造成每次编辑有8个px差错。
所以,我们在最开始的ele.getBoundingClientRect那一步开始,要加上margin
const { x, y, width, height } = ele.getBoundingClientRect();
// 获取body自带的margin
const bodyMargin = getPxNumber(getComputedStyle(document.body).margin);
const controlWrapper = document.createElement("div");
const _style_ = new Proxy(controlWrapper.style, {
get(o, key) {
let originalStyleValue = Reflect.get(o, key);
if (
["width", "height", "left", "top"].includes(key) &&
!originalStyleValue
) {
originalStyleValue = controlWrapper.getBoundingClientRect()[key];
}
return originalStyleValue;
},
set(o, key, val) {
const pxNumber = getPxNumber(val);
// 设置位置的时候,需要去掉自带的margin影响
if (["left", "top"].includes(key)) {
ele.style[key] = `${pxNumber - bodyMargin}px`;
} else if (["width", "height"].includes(key)) {
ele.style[key] = val;
}
Reflect.set(o, key, val);
return val;
}
});
复制代码
上面代码全是pc的鼠标事件,移动端加不能用了,当然,再写一份就可以。作为完美追求者,这种事情一定不会做的,我们看看移动端touch和pc的mouse在本功能上最主要的区别:
自己给原型对象挂一个新的事件绑定。写好后,第一步是全局替换原有的名字
const MOBILE_MAP = {
mousedown: "touchstart",
mousemove: "touchmove",
mouseup: "touchend"
};
HTMLDocument.prototype._addEventListener = function(key, cb, ...rest) {
document.addEventListener(key, cb, ...rest);
document.addEventListener(MOBILE_MAP[key], cb, ...rest);
};
HTMLDocument.prototype._removeEventListener = function(key, cb, ...rest) {
document.removeEventListener(key, cb, ...rest);
document.removeEventListener(MOBILE_MAP[key], cb, ...rest);
};
复制代码
替换名字后,在代码中clientX、clientY要兼容双端:
// ...
let x0 = e.clientX || e.touches[0].clientX;
let y0 = e.clientY || e.touches[0].clientY;
const handleMove = ({
touches,
clientX = touches[0].clientX,
clientY = touches[0].clientY,
target
}) => {}
// ...
扩展:最开始的时候,传入一个config对象,每一个函数都会透传这个对象,这个对象贯穿整个过程,控制每一个流程可以个性化配置
代码比较多,具体代码见codesandbox,还有旋转功能没有实现,其实就是扩展一下控件即可
关注公众号《不一样的前端》,以不一样的视角学习前端,快速成长,一起把玩最新的技术、探索各种黑科技