咱們若是使用過ppt、keynote,元素的小控件必定少不了,能夠實現修改修改寬高和位移,大概是這樣css
最終效果預覽:html
下面,咱們從0開始,使用原生js實現這個效果,並封裝成插件前端
cursor
爲all-scroll
❌ 錯誤示範canvas
給元素加上mousedown(按下的時候)事件,此時開始綁定mousemove;當鼠標彈起,移除mousemove事件綁定。也就是鼠標在元素上按下的時候,每次move都移動元素,鼠標彈起的時候,清除事件綁定app
mousemove事件觸發的時候,計算本次位置和上次位置x、y座標(即left、top)差值,並加上left、top位置,便可得到拖動後的新位置dom
// 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);
});
});
複製代碼
慢慢拖、慢慢拖,很okide
可是......試試快速拖動會發生什麼事情,是否是有一種手滑的效果?而後元素跟丟了。若是你的div很大,跟丟的機率會小不少函數
✅ 正確的作法學習
給頂部節點(如document)加上事件綁定,而後經過事件代理來實現拖拽元素準肯定位:ui
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,還有旋轉功能沒有實現,其實就是擴展一下控件便可
關注公衆號《不同的前端》,以不同的視角學習前端,快速成長,一塊兒把玩最新的技術、探索各類黑科技