🔪🔪🔪图片裁剪器✂✂✂一个相当简单好用的图片裁剪器,仅使用Javascript即可完成,可自由缩放大小,移动,实现
写在开头
嘿嘿!各位早上好吖!👻
七月,对于小编来说是繁忙的,各种琐事缠身😔,但愿能赶紧挺过七月,迎来一个快乐的八月吧,还是开开心心当一条咸鱼的日子过得快乐啊。
还有,七月有了一个新的Flag,准备打卡满一个月的运动,运动对于小编之前来说并没有刻意去做,就是顺其自然(就是太懒😆)。而七月一号那天不知道抽什么风🤡,突然就想着需要开始运动了,那就行动起来吧。。。
小编选择的运动是步行,大概就是每天晚上会花一到两小时到处去走走,每天大概能走个五公里以上吧,快把居住的附近大街小巷给走熟了。
当前,Em...算是坚持住了,希望继续坚持下去吧,拭目以待哈。😋

回到正题,这次要分享是一个图片裁剪器相关的内容,具体效果如下,请诸君按需食用。

前置知识
🍊
在开始主题过程之前,咱们先来看一个小知识点,且看:
<!DOCTYPE html>
<html>
<head>
<style>
.box {
width: 600px;
height: 400px;
border: 1px solid red;
position: relative;
}
.mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
}
</style>
</head>
<body>
<div class="box">
<div class="mask"></div>
</div>
</body>
</html>
很简单,有一个 .mask
元素会铺满 .box
元素,这个...就不用过多解释,相信都能理解哈。

而当我们改变 .mask
元素的样式,如下:

top/right/bottom/left
等属性可以替换成 inset 属性,即inset: 10px 10px 10px 10px;
,inset
属于为它们的简写模式。
可以看到,通过改变 top/right/bottom/left
等属性,咱们也是能控制 .mask
元素的大小与位置❗❗❗
如果咱们能动态不断去改变这些属性,那......
这个操作呢,也就是咱们最终案例中的那个框框变化大小与移动的原理所在了,有点意思不?😋
看了网上一些裁剪图片功能,这部分功能大多是通过宽度(width/height
)与偏离量(translate
)来完成,咱这也算是一个新思路吧。🙊
🍊🍊
再来看这个案例:
<div class="box">
<div class="mask">
<div class="mask__view"></div>
</div>
</div>
.mask {
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
background-color: rgba(0, 0, 0, 0.5);
}
.mask__view {
width: 100%;
height: 100%;
background-image: url('https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/8506b8c045b644448b24c93d4ca90119~tplv-73owjymdk6-watermark.image?policy=eyJ2bSI6MywidWlkIjoiMTkwODQwNzkxOTE4NDY3MCJ9&rk3s=e9ecf3d6&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1720751671&x-orig-sign=1z9wkuIkI5wlSg43cAnqrhBvil8%3D');
background-size: 100% 100%;
clip-path: inset(50px 50px 50px 50px);
}
咱们给 .mask
元素增加了一个子元素(.mask__view
),它占满父元素,并有一个背景图片,并通过 clip-path 属性进行了裁剪。
clip-path
属于不熟悉的小伙伴,可要去好好瞅瞅。传送门
效果如下:

Em...怎么说呢,inset(50px 50px 50px 50px) 函数可以用来裁剪一个矩形,但要注意,它和我们上面提到的 inset 属性可不是同一个东西,一个是函数,一个是属性。但是呢,在此时的场景下,它们的应用又可以说两者是等同的。
从图可以看到,裁剪后,.mask__view
元素就剩下中间的矩形了,咱们也能瞅到 .mask
元素的淡黑色背景。
然后呢❓这有什么用❓
咱再来增加一个元素,如下:
<div class="box">
<img style="width: 100%;height: 100%;" src="https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/8506b8c045b644448b24c93d4ca90119~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5qmZ5p-Q5Lq6:q75.awebp?rk3s=f64ab15b&x-expires=1728861933&x-signature=dhZ5gCGrR4wPh7mTEiUucb1TKNI%3D">
<div class="mask">
<div class="mask__view"></div>
</div>
</div>
再来看效果:

😦 这......
咱增加了一张图片,垫底用,就不会看到黑黑的四边了。
还没完,咱们来动态不断调整 inset()
函数的值:

这像不像图片裁剪器变化大小时的样子?
好,两个前置小知识就讲到这里了,其实两个原理加起来就是整个图片裁剪器的整个原理过程了。最后,再次提醒,不要把 inset
属性与 inset()
函数搞混了,接下来,咱们就来看看最终案例的整体实现过程。
基础布局与样式
整个 HTML
结构可以分成下面的三个部分,自己瞅瞅哈。😋
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<input id="file" type="file" />
<div id="container" class="container">
<!-- 底图 -->
<img id="bgImage" width="100%" height="100%" />
<!-- 中间预览区 -->
<div id="mask" class="mask">
<div id="maskView" class="mask__view"></div>
</div>
<!-- 裁剪器 -->
<div id="cropper" class="cropper">
<div id="cropperView" class="cropper__view">
<span class="cropper-dashed dashed-h"></span>
<span class="cropper-dashed dashed-v"></span>
<span class="cropper-center"></span>
<span class="cropper-move"></span>
<span class="cropper-line line-n"></span>
<span class="cropper-line line-e"></span>
<span class="cropper-line line-s"></span>
<span class="cropper-line line-w"></span>
<span class="cropper-point point-e"></span>
<span class="cropper-point point-n"></span>
<span class="cropper-point point-w"></span>
<span class="cropper-point point-s"></span>
<span class="cropper-point point-nw"></span>
<span class="cropper-point point-ne"></span>
<span class="cropper-point point-se"></span>
<span class="cropper-point point-sw"></span>
</div>
</div>
</div>
</body>
</html>
/* style.css */
body {
padding: 10px;
margin: 0;
height: 100vh;
box-sizing: border-box;
}
.container {
width: 600px;
height: 400px;
box-sizing: border-box;
position: relative;
margin-top: 10px;
}
/* 中间预览区 */
.mask {
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
position: absolute;
top: 0;
left: 0;
display: none;
}
.mask__view {
width: 100%;
height: 100%;
background-size: 100% 100%;
clip-path: inset(0 0 0 0);
}
/* 裁剪器 */
.cropper {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: none;
}
.cropper__view {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
user-select: none;
outline: 1px solid #39f;
}
.cropper-dashed { /* 虚线 */
border: 0 dashed #eee;
display: block;
opacity: .5;
position: absolute;
}
.cropper-dashed.dashed-h {
border-bottom-width: 1px;
border-top-width: 1px;
height: 33.33333%;
left: 0;
top: 33.33333%;
width: 100%;
}
.cropper-dashed.dashed-v {
border-left-width: 1px;
border-right-width: 1px;
height: 100%;
left: 33.33333%;
top: 0;
width: 33.33333%;
}
.cropper-center { /* 中心点 */
display: block;
height: 0;
left: 50%;
opacity: .75;
position: absolute;
top: 50%;
width: 0;
}
.cropper-center:after, .cropper-center:before {
background-color: #eee;
content: " ";
display: block;
position: absolute;
}
.cropper-center:before {
height: 1px;
left: -3px;
top: 0;
width: 7px;
}
.cropper-center:after {
height: 7px;
left: 0;
top: -3px;
width: 1px;
}
.cropper-line { /* 四边线 */
background-color: #39f;
display: block;
height: 100%;
opacity: .1;
position: absolute;
width: 100%;
}
.cropper-line.line-e {
cursor: e-resize;
right: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-n {
cursor: n-resize;
height: 5px;
left: 0;
top: -3px;
}
.cropper-line.line-w {
cursor: w-resize;
left: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-s {
bottom: -3px;
cursor: s-resize;
height: 5px;
left: 0;
}
.cropper-point { /* 六个点 */
background-color: #39f;
height: 5px;
opacity: .75;
width: 5px;
display: block;
position: absolute;
}
.cropper-point.point-e {
cursor: e-resize;
margin-top: -3px;
right: -3px;
top: 50%;
}
.cropper-point.point-n {
cursor: n-resize;
left: 50%;
margin-left: -3px;
top: -3px;
}
.cropper-point.point-w {
cursor: w-resize;
left: -3px;
margin-top: -3px;
top: 50%;
}
.cropper-point.point-s {
bottom: -3px;
cursor: s-resize;
left: 50%;
margin-left: -3px;
}
.cropper-point.point-ne {
cursor: ne-resize;
right: -3px;
top: -3px;
}
.cropper-point.point-nw {
cursor: nw-resize;
left: -3px;
top: -3px;
}
.cropper-point.point-sw {
bottom: -3px;
cursor: sw-resize;
left: -3px;
}
.cropper-point.point-se {
bottom: -3px;
cursor: se-resize;
opacity: 1;
right: -3px;
}
.cropper-move { /* 移动区 */
display: block;
height: 100%;
opacity: .1;
position: absolute;
width: 100%;
cursor: move;
background-color: #fff;
left: 0;
top: 0;
}
案例全部的样式都在这里了,有点多,但其实并不复杂,就是为了构建裁剪器的样式,有八个点,四条边框线,中间四条虚线,还有中心点一个叉,大概就是这样。🙈

具体逻辑实现
由于仅使用了 JS
来完成,所以咱们只能通过原生的API来获取一些 DOM
元素了。🙊
<script>
document.addEventListener("DOMContentLoaded", () => {
const file = document.getElementById("file");
const bgImage = document.getElementById("bgImage");
const mask = document.getElementById("mask");
const maskView = document.getElementById("maskView");
const cropper = document.getElementById("cropper");
const cropperView = document.getElementById("cropperView");
// 监听图片上传
file.addEventListener("change", (e) => {
const target = e.target.files[0];
const imgURL = URL.createObjectURL(target);
bgImage.src = imgURL;
maskView.style.backgroundImage = `url('${imgURL}')`;
bgImage.onload = () => {
createCropper();
};
});
let initDimention = [50, 50, 50, 50];
const TOP = 0, RIGHT = 1, BOTTOM = 2, LEFT = 3;
/** @name 生成裁剪器 **/
function createCropper() {
mask.style.display = "block";
cropper.style.display = "block";
setDimention(initDimention);
}
/** @name 设置裁剪器尺寸 **/
function setDimention(dimention) {
// inset也可以换成top/right/bottom/left
cropperView.style.inset = `${dimention[TOP]}px ${dimention[RIGHT]}px ${dimention[BOTTOM]}px ${dimention[LEFT]}px`;
maskView.style.clipPath = `inset(${dimention[TOP]}px ${dimention[RIGHT]}px ${dimention[BOTTOM]}px ${dimention[LEFT]}px)`;
}
});
</script>
setDimention
方法用于控制裁剪器的大小与移动,我们只要传入 dimention
参数就行,该参数是一个数组([0, 0, 0, 0]
),固定有四项,代表 top/right/bottom/left
属性的值。
dimention
参数的值,与上一次相比,有一项或者两项值变化时,即是裁剪器的大小变化了;如果四项值都变化了,则是裁剪器移动了。😶
效果如下:

布局这样子就算是完成了,比较简单哈,但裁剪器还无法操作,还有两个功能需要我们来实现,咱们一个一个来看。
大小变化
先来瞅瞅如何才能实现拖动裁剪器四周就能改变裁剪器的大小呢❓
先来给边框四周的元素(四条边框线、八个点)添加事件:
// 是否在拖动中
let dragging = false;
// 记录鼠标按下的位置
let startPoint = [0, 0];
// 当前裁剪器的实时尺寸
let currentDimention = [];
// 记录裁剪器开始的尺寸
let startDimention = [];
/** @name 生成裁剪器 **/
function createCropper() {
mask.style.display = "block";
cropper.style.display = "block";
setDimention(initDimention);
// 边框四周:四条边框线、八个点
const elements = [...document.querySelectorAll(".cropper-line"), ...document.querySelectorAll(".cropper-point")];
elements.forEach(ele => {
// 添加鼠标按下事件
ele.addEventListener("mousedown", e => {
dragging = true;
// 记录鼠标初始位置
const { clientX, clientY } = e;
startPoint[0] = clientX;
startPoint[1] = clientY;
// 记录裁剪器初始尺寸,注意浅拷贝一下
startDimention = [...initDimention];
// 记录裁剪器实时尺寸
currentDimention = initDimention;
});
});
}
咱们存了四个变量,主要是标记是否开始拖动中、鼠标信息、裁剪器尺寸等,都是后续要用的一些变量,这里就先给全存上了。
咱监听了鼠标按下事件(mousedown
),那就肯定还有 mousemove
与 mouseup
事件要处理的😗,且看:
// 鼠标移动事件
document.body.addEventListener("mousemove", e => {
if (!dragging) return;
const { clientX, clientY } = e;
// // 计算鼠标拖动的距离
let diffX = clientX - startPoint[0];
let diffY = clientY - startPoint[1];
// 假设是拖动了顶部边框
currentDimention[TOP] = startDimention[TOP] + diffY;
setDimention(currentDimention);
});
// 鼠标按键抬起事件
document.body.addEventListener("mouseup", () => {
dragging = false;
});
注意,这两个事件是要在 document
上监听的,原因嘛,你懂的。😋
咱们假设了当我们拖动的是顶部边框,效果如下:

感觉还行吧?🙈
那么,可以推导出其他方向的边框,如下:
currentDimention[TOP] = startDimention[TOP] + diffY;
currentDimention[RIGHT] = startDimention[RIGHT] - diffX;
currentDimention[LEFT] = startDimention[LEFT] + diffX;
currentDimention[BOTTOM] = startDimention[BOTTOM] - diffY;
注意,
right
与bottom
是要减去鼠标移动的距离。![]()
现在,可不能直接把四个边框的赋值都加上,要不裁剪器就会变成移动的操作,不信你可以自个尝试看看。😁
接下来,我们得来确定拖动的是那个方向的边框,对应使用上面一边的赋值就行啦。
那么,如何来确定是那个方向的边框进行了拖动呢❓
方式有很多,最简单的是,当我们在按下鼠标的时候,进行标记就行了。当然,这是完全可以的,这种方式我们放到下面的"另一个版本实现"中实现,先来瞅瞅小编的这种:
// ...
// 记录哪个方向的边框进行了拖动,top=1; right=-1; bottom=-1; left=1;
let direction = [0, 0, 0, 0];
function createCropper() {
// ...
const moveElement = document.querySelector(".cropper-move");
moveElement.addEventListener("mousemove", e => {
if (dragging) return;
const { clientX, clientY } = e;
const { x: moveElementX, y: moveElementY} = moveElement.getBoundingClientRect();
// dx越大越右,dy越大越向下
const dx = clientX - moveElementX;
const dy = clientY - moveElementY;
// 靠近边框的距离,如当鼠标靠近顶部边框小于5时,意味想要拖动的顶部边框
const threshold = 5;
if (dy <= threshold) {
// 顶部边框拖动
direction[TOP] = 1;
direction[BOTTOM] = 0;
} else if (dy >= moveElement.clientHeight - threshold) {
// 底部边框拖动
direction[TOP] = 0;
direction[BOTTOM] = -1;
} else {
// 不会是顶部或者底部的边框
direction[TOP] = 0;
direction[BOTTOM] = 0;
}
if (dx <= threshold) {
// 左边边框拖动
direction[LEFT] = 1;
direction[RIGHT] = 0;
} else if (dx >= moveElement.clientWidth - threshold) {
// 右边边框拖动
direction[LEFT] = 0;
direction[RIGHT] = -1;
} else {
direction[LEFT] = 0;
direction[RIGHT] = 0;
}
});
// ...
}
咱们给 .cropper-move
元素添加一个 mousemove
事件,通过鼠标位置与元素位置计算出鼠标是接近于哪个方向的边框,从而能推导出哪个方向的边框可能将进行拖动的操作。
并且,使用了 direction
变量进行了记录,它是一个只有四项的数组。
为什么要使用一个 direction
数组变量来记录拖动的边框呢❓
因为假设当 direction = [1, -1, 0, 0]
的时,咱们是能推导出是右上角的端点进行了拖动,这样就不用考虑端点的情况了,是不是稍微有点妙吧?😋
而这也是为什么一开始要使用只有四项的数组来存储裁剪器尺寸(currentDimention/startDimention/...
)的原因了,两者其实是呼应的。😯
现在等于是有了拖动的目标标记,来看看拖动具体的处理过程:
currentDimention[TOP] = startDimention[TOP] + direction[TOP] * diffY;
currentDimention[RIGHT] = startDimention[RIGHT] + direction[RIGHT] * diffX;
currentDimention[BOTTOM] = startDimention[BOTTOM] + direction[BOTTOM] * diffY;
currentDimention[LEFT] = startDimention[LEFT] + direction[LEFT] * diffX;
直接将 currentDimention[TOP] = startDimention[TOP] + diffY;
替换成上面这四行即可,因为使用了乘法,当拖动 TOP
方向的时候,其他方向都是 0
,乘完后就没了。😎

现在四边与端点处都可以正常拖动变化大小了。
只是好像还有点奇怪,裁剪器会变成了一条线?这不是我们所期望的,咱们可以希望它能有一个最小的宽度与高度限制🚫。
其实就是处理一些边界情况,直接贴出最终的结果吧,如下:
// 最小宽度与高度
const minWidth = 30;
const minHeight = 30;
currentDimention[TOP] = Math.min(
Math.max(startDimention[TOP] + direction[TOP] * diffY, 0),
cropper.clientHeight - currentDimention[BOTTOM] - minHeight
);
currentDimention[RIGHT] = Math.min(
Math.max(startDimention[RIGHT] + direction[RIGHT] * diffX, 0),
cropper.clientWidth - currentDimention[LEFT] - minWidth
);
currentDimention[BOTTOM] = Math.min(
Math.max(startDimention[BOTTOM] + direction[BOTTOM] * diffY, 0),
cropper.clientHeight - currentDimention[TOP] - minHeight
);
currentDimention[LEFT] = Math.min(
Math.max(startDimention[LEFT] + direction[LEFT] * diffX, 0),
cropper.clientWidth - currentDimention[RIGHT] - minWidth
);
稍微有点复杂,细细瞅瞅看哈。
贴个辅助计算图:

移动
好了,拖动改变大小整体大概就是这样了。接下来就是移动的操作,有了前面的铺垫,移动就很好解决了。
上述,咱们在前置知识讲过,在某些场景下,通过同时改变元素 top/right/bottom/left
四个属性就能控制其移动。
换成当前的情况,本质就是同时去改变 currentDimention
数组四个值,就能让裁剪器移动起来了。
来看看是如何做的:
// ...
// 是否在拖动中
let moving = false;
function createCropper() {
// ...
const moveElement = document.querySelector(".cropper-move");
moveElement.addEventListener("mousemove", e => {
// ...
if (!direction.some((e) => e)) {//当 direction=[0, 0, 0, 0] 证明没有靠近边框,可能要进行移动了
direction[TOP] = 1;
direction[BOTTOM] = -1;
direction[LEFT] = 1;
direction[RIGHT] = -1;
moving = true;
} else {
moving = false;
}
});
// 四条边框线、八个点、移动元素moveElement
const elements = [moveElement, ...document.querySelectorAll(".cropper-line"), ...document.querySelectorAll(".cropper-point")];
// ...
}
document.body.addEventListener("mouseup", () => {
dragging = false;
moving = false;
});
给 moveElement
元素也添加 mousedown
事件,然后判断是边框的拖动还是想要移动,去改变 direction
的值。
移动效果:

呃...有点问题吧,靠边的时候变小了?😐
这是因为没处理边界的情况,导致移动到边的时候裁剪器会变小。
解决:
// 鼠标移动事件
document.body.addEventListener("mousemove", e => {
// ...
// 处理移动的边框情况
if (moving) {
diffX = Math.min(
Math.max(diffX, -startDimention[LEFT]),
startDimention[RIGHT]
);
diffY = Math.min(
Math.max(diffY, -startDimention[TOP]),
startDimention[BOTTOM]
);
}
// 最小宽度与高度
// ...
});
这样就算是大功告成。😌
另一版本实现(推荐)
上面,咱们讲过"如何来确定是那个方向的边框进行了拖动呢❓"还有其他的方式,现在来看看是如何做的,也比较推荐这种方式,上面那种就当做是一个"过客"吧(快忘记快忘记)。🙊
<span class="cropper-line line-n" data-action="top"></span>
<span class="cropper-line line-e" data-action="right"></span>
<span class="cropper-line line-s" data-action="bottom"></span>
<span class="cropper-line line-w" data-action="left"></span>
<span class="cropper-point point-nw" data-action="top-left"></span>
<span class="cropper-point point-ne" data-action="top-right"></span>
<span class="cropper-point point-se" data-action="bottom-right"></span>
<span class="cropper-point point-sw" data-action="bottom-left"></span>
咱们先给四个边框与四个端点添加 data-*
信息。
修改 createCropper
方法:
/** @name 生成裁剪器 **/
function createCropper() {
mask.style.display = "block";
cropper.style.display = "block";
setDimention(initDimention);
const moveElement = document.querySelector(".cropper-move");
// 四条边框线、八个点、移动元素moveElement
const elements = [moveElement, ...document.querySelectorAll(".cropper-line"), ...document.querySelectorAll(".cropper-point")];
elements.forEach(ele => {
// 添加鼠标按下事件
ele.addEventListener("mousedown", e => {
dragging = true;
const { clientX, clientY } = e;
startPoint[0] = clientX;
startPoint[1] = clientY;
currentElement = ele;
currentDimention = initDimention;
startDimention = [...initDimention];
// 当是移动元素时,改变direction变量
if(ele === moveElement) {
direction[TOP] = 1;
direction[BOTTOM] = -1;
direction[LEFT] = 1;
direction[RIGHT] = -1;
moving = true;
}
const action = ele.dataset.action;
switch(action) {
// 四条线
case 'top': direction[TOP] = 1; direction[BOTTOM] = 0; break;
case 'right': direction[LEFT] = 0;direction[RIGHT] = -1; break;
case 'bottom': direction[TOP] = 0;direction[BOTTOM] = -1; break;
case 'left': direction[LEFT] = 1;direction[RIGHT] = 0; break;
// 四个端点
case 'top-right': direction[TOP] = 1;direction[RIGHT] = -1; break;
case 'top-left': direction[TOP] = 1;direction[LEFT] = 1; break;
case 'bottom-left': direction[BOTTOM] = -1;direction[LEFT] = 1; break;
case 'bottom-right': direction[BOTTOM] = -1;direction[RIGHT] = -1; break;
}
});
});
}
// 鼠标按键抬起事件
document.body.addEventListener("mouseup", () => {
dragging = false;
moving = false;
direction[TOP] = 0;
direction[BOTTOM] = 0;
direction[LEFT] = 0;
direction[RIGHT] = 0;
});
主要修改了两个地方,把 .cropper-move
元素的 mousemove
事件给移除掉了,通过 data-*
来确定是那个方向的边框进行了拖动。
在鼠标抬起(mouseup
)事件中做一些重置操作。
完整源码
传送门 👈👈👈
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。 老样子,点赞+评论=你会了,收藏=你精通了。
转载自:https://juejin.cn/post/7391160093565829160