css clip-path裁剪可视化工具真给力,我也想造这个轮子
背景
项目开发中,有一个设计稿,要求把图片裁剪成直角梯形。面对这个需求,图片裁剪虽然你会想到用css clip-path polygon来实现,可是polygon具体每个属性的值该设置为多少,你在大脑中肯定不会像设置一个网页元素的width/height值反应那么快, 你需要一番思考与调试。状态好,思路清晰的时候,你花费的时间相对较少,状态差,大脑转不动的时候,你可能会颇费周折,才能把这个效果做出来。那么,有没有办法,让你在任何时候,都能很快地开发出这样的展示效果? 答案是肯定的,借助一些图片裁剪可视化工具, 比如说像clippy这样的工具,拖动一下裁剪点,分分钟就能获取裁剪一个直角梯形polygon每个坐标点应该设置的属性值。是不是感觉有神器加持,开发效率获得了极大提高。不得不佩服,这个工具的设计与开发者是真的有才, 如果我们也想造一个这样的轮子,该怎么做呢?

效果演示
给大家演示一下, 用自己开发的clip-path可视化工具,如何实现项目中的直角卡片效果。拖动左上,右上,右下三个图片裁剪拖拽点,就能裁剪出直角梯形效果,然后点击css代码展示区域的复制按钮,就能将css设置属性复制到剪切板,用Ctrl+V大法复制到你的代码中,搞定,今天可以偷得半日闲了。
可视化裁剪实现思路
clippy网站的轮子何其多,全部都造, 迫于专注力有限,造不过来。所以就得挑一个有代表性的,选择项目中用到的那个直角梯形图片裁剪轮子,再合适不过了。选定了目标之后,撸起袖子,开干。
第一步 先把静态页面画出来
看clippy官网直角梯形图片裁剪的界面展示效果, 页面分为上下两部分,上面是拖拽工作区,下方是代码展示区。上下两部分水平居中,所以页面容器应该采用弹性列布局。代码展示区域比较简单, 难点是拖拽工作区的功能实现。拖拽工作区可进一步划分成三部分。从前往后看,最前面的是四个拖拽点区域,中间是裁剪面板区域,最后面是带有边框的蒙层面板。
基于上面的分析,页面的结构为:
<body class="page-container">
<!-- 工作区 -->
<main id="play-ground-ele" class="play-ground">
<!-- 蒙层面板 -->
<section class="shadow-board"></section>
<!-- 裁剪面板 -->
<section id="clip-board-ele" class="clip-board"></section>
<!-- 四个拖拽点 -->
<section id="points-ele" class="points">
<div id="point1-ele" class="point"></div>
<div id="point2-ele" class="point"></div>
<div id="point3-ele" class="point"></div>
<div id="point4-ele" class="point"></div>
</section>
</main>
<!-- css代码区域 -->
<footer class="show-css-code">
<pre id="show-code-ele" class="code-area"></pre>
</footer>
</body>
我们从外到内,从上到下,把每部分的展示效果实现一下:
- 页面容器的主要样式
.page-container {
display: flex;
background: #fbfcf7;
align-items: center;
flex-direction: column;
}
- 拖拽工作区的主要样式
拖拽工作区要采用相对定位,父相子绝这种定位方式,设置子元素的偏移时,如砍瓜切菜一般丝滑柔顺。
.play-ground {
position: relative;
width: 300px;
height: 300px;
box-shadow: inset 0 0 0 10px #fbfcf7;
}
- 四个拖拽点的主要样式
拖拽点容器要采用绝对定位, 这样比较容易设置与拖拽工作区容器的位置偏移。拖拽点的鼠标指针要设置成手形 cursor: grab;
看起来可以拖拽。给拖拽点加一个透明效果opacity: 0.8;
,使之看起来是裁剪面板紧密结合在一起的。当拖拽点处于拖拽状态时,让鼠标指针消失cursor: none;
出现一个镂空效果,鲜明地指示拖拽点处于拖动状态。另外还要说一下,最新的Chrome已经支持类似Less/Sass嵌套语法,下面的写法是可以正确运行的。
/* 拖拽点容器 */
.points {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.point {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
box-shadow: inset 0 0 0 10px;
opacity: 0.8;
transition: opacity 0.25s;
cursor: grab;
&.is-dragging {
z-index: 100;
cursor: none;
box-shadow: inset 0 0 0 3px;
transition: box-shadow 0;
}
}
}
- 裁剪和蒙层面板样式
裁剪面板展示裁剪图片效果,背景图片是从clippy官网下载下来的,裁剪属性是用js动态设置的,形如clip-path: polygon(20% 0%, 80% 0%, 100% 100%, 0% 100%);
这个后面再讲。蒙层的主要作用是显示裁剪边界与范围,所以要给蒙层加一个边框border: 1px solid #ccc;
而给裁剪和蒙层面板上下左右添加10px的偏移,是为了让拖拽点一半嵌入到这两个蒙层上,看着好像连接在一起。
.clip-board,
.shadow-board {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
bottom: 10px;
}
.shadow-board {
border: 1px solid #ccc;
}
.clip-board {
background-size: cover;
background-position: center center;
background-image: url(./sparkler.jpg);
}
- 代码展示区样式
代码展示区由四个动态code标签构成,每个标签展示每个拖拽点的属性值,是用js创建出来的。code标签有个动效,当改变标签属性值的时候,会展示一个逐渐变大的波纹效果。这个效果是用code-item::after
伪元素实现的, 正常状态下,伪元素处于完全透明状态,不可见。改变code标签属性值时,会在标签伪元素上添加一个变大与透明渐变的波纹效果。
.show-css-code {
margin-top: 20px;
background: #100a09;
color: #9a8297;
padding: 0.75rem;
box-shadow: 0 1px 2px rgba(16, 10, 9, 0.15);
.code-area {
pointer-events: none;
cursor: default;
.code-item {
display: inline-block;
position: relative;
vertical-align: baseline;
&:nth-last-of-type(1) {
animation-delay: 0.125s;
}
&:nth-last-of-type(2) {
animation-delay: 0.25s;
}
&:nth-last-of-type(3){
animation-delay: 0.375s;
}
&:nth-last-of-type(4){
animation-delay: 0.5s;
}
&::after {
display: block;
position: absolute;
content: "";
width: 80px;
height: 80px;
border-radius: 50%;
background: currentColor;
top: calc(50% - 40px);
left: calc(50% - 40px);
transform: scale(0);
will-change: transform, opacity;
opacity: 0;
}
&.changing {
font-weight: bold;
&::after {
animation: water-wave 1.25s;
animation-delay: inherit;
}
}
}
}
}
// 水波动画
@keyframes water-wave {
20% {
transform: none;
opacity: 0.5;
}
to {
opacity: 0;
transform: scale(1.2);
}
}
第二步 让拖拽点动起来
结构和样式已经讲完了, 现在说说行为如何实现。首先要初始化页面。要做的工作是:
- 设置每个裁剪点的初始位置
这里有个转换逻辑, 每个裁剪点的x,y轴偏移量是数值ele.style.transform = translate(${offsetX}px,${offsetY}px);
, 而裁剪面板每个裁剪点的属性值是百分比(如下图所示), 因此需要用toPercent(num)
函数转换一下。裁剪面板每个属性值设置成百分比的好处是通用性更强, 不管裁剪面板是什么尺寸, 复制出来的百分比属性值都可以直接使用,不需转换。
- 设置裁剪面板的初始裁剪效果
设置clipboardEle元素的clip-path属性即可。clipboardEle.style.cssText = clip-path: polygon(x1 y1, x2 y2,x3 y3, x4 y4);
- 在代码展示区展示初始裁剪设置值
要给每个属性值创建一个code标签,借助于标签给每个属性值设置字体颜色和属性值改变时的动效。注意一下这里的isMove
判断条件, 用来标记是否正在拖拽裁剪点。 页面初始化时,还未拖拽过裁剪点,不能给code元素添加changing
样式类, 因为一添加这个样式类,就会出现动画效果。
// 展示代码需要拼接四个code标签
const showPoints = coords.map((item, pointIndex) => `<code class="code-item ${isMove && pointIndex === index && 'changing'}">${coords[pointIndex].axis.xPer}% ${coords[pointIndex].axis.yPer}%</code>`);
// 设置展示代码
showCodeEle.innerHTML = `clip-path: polygon(${showPoints.join(',')});`;
页面初始化的全部代码如下:
// 裁剪板
const clipboardEle = document.querySelector('#clip-board-ele');
// 展示css代码
const showCodeEle = document.querySelector('#show-code-ele');
// 四个拖拽点的初始坐标(百分比)
let coords = [
{ id: 'point1-ele', axis: { xPer: 20, yPer: 0 } },
{ id: 'point2-ele', axis: { xPer: 80, yPer: 0 } },
{ id: 'point3-ele', axis: { xPer: 100, yPer: 100 } },
{ id: 'point4-ele', axis: { xPer: 0, yPer: 100 } }
];
// 裁剪图片初始化
clipPicInit();
function clipPicInit() {
coords.forEach(({ id, axis }, index) => {
const ele = document.querySelector(`#${id}`);
const offsetX = shadowBoardSquare * axis.xPer / 100;
const offsetY = shadowBoardSquare * axis.yPer / 100;
setEleAttr(index, ele, { offsetX, offsetY }, false);
});
}
/**
* 设置元素属性
* index-拖动的元素序号
* ele-拖拽的元素
* offsetX, offsetY 鼠标偏移量
* isMove 是否正在拖动裁剪点
* */
function setEleAttr(index, ele, { offsetX, offsetY }, isMove = true) {
// 裁剪图片设置属性值是百分比
coords[index].axis.xPer = toPercent(offsetX);
coords[index].axis.yPer = toPercent(offsetY);
// 图片裁剪代码是拼接属性值文本
const points = coords.map((item, index) => `${coords[index].axis.xPer}% ${coords[index].axis.yPer}%`);
// 展示代码需要拼接四个code标签
const showPoints = coords.map((item, pointIndex) => `<code class="code-item ${isMove && pointIndex === index && 'changing'}">${coords[pointIndex].axis.xPer}% ${coords[pointIndex].axis.yPer}%</code>`);
// 设置拖拽点的偏移--注意translate后面不能加分号,加了设置的属性会不生效
ele.style.transform = `translate(${offsetX}px,${offsetY}px)`;
// 设置图片裁剪属性
clipboardEle.style.cssText = `clip-path: polygon(${points.join(',')});`;
// 设置展示代码
showCodeEle.innerHTML = `clip-path: polygon(${showPoints.join(',')});`;
}
/**
* 将整数转换成百分比
* num: css像素值
* */
function toPercent(num) {
return (parseFloat(num / shadowBoardSquare) * 100).toFixed(0);
}
接着我们来看看如何让拖拽点可以拖动。给拖拽点容器绑定mousedown
事件,之所以把mousedown
事件绑定在包含四个拖拽点的容器上,而不是每个拖拽点上,是因为事件委托处理事件绑定执行效率更高。但要多做一步工作,就是判断一下事件源,是不是需要处理的事件源。另外mousedown
事件中的第一个语句e.preventDefault();
, 看似不起眼,实则非常重要,少了这句话,在拖拽的过程中就会发生一些诡异的现象,明明鼠标左键已经释放了,发现还是可以拖拽。这句话的作用是禁止浏览器默认行为, 不然mouseup事件有时不响应,导致无法清除mousemove事件。
// 鼠标左键按下时 添加mousemove事件和拖拽裁剪点动态样式
pointsEle.addEventListener('mousedown', function (e) {
e.preventDefault();
// 只监听鼠标左键按下事件
if (e.button !== 0) return false;
// 为了提高事件绑定性能,采用的是事件委托的写法,所以要判断触发事件的元素
if (pointIds.includes(e.target.id)) {
e.target.classList.add('is-dragging');
document.addEventListener('mousemove', moveCb);
}
});
再看看mousemove
事件。我不说你可能没有疑惑。为什么要将mousemove
事件绑定在document
元素上, 而不是拖拽点容器pointsEle
元素上。其实这里也是有讲究的,如果把mousemove
事件绑定在pointsEle
元素上,你会发现,拖拽裁剪点拖动太快的话,指针会跑飞,鼠标左键还处于按下状态,可是拖拽点无法拖动了。所以要把mousemove
事件绑定在顶层document
元素上,这样就不会出现指针跑飞的情况了。
此外,mousemove
事件的回调函数moveCb
里面有一段逻辑,你也要关注一下。就是拖拽点有时会被拖出拖拽点容器,看起来是不是不正常。如何修复这个问题? 答案是判断一下鼠标指针相对于可视区域的偏移量clientX/clientY
有没有超出拖拽点容器的上下左右四个边界。如超出就进行复位。拖拽点容器的左边界是offsetLeft + boxOffset
, 而不是offsetLeft
, 这一点要注意。boxOffset
是裁剪面板相对裁剪工作区容器设置的偏移量。offsetLeft
是拖拽工作区相对于可视区域的左边距。那么其它三个方向上的边界是不是就很容易计算出来。
- 右边距 = 拖拽工作区距离可视区域左边距(offsetLeft)+裁剪面板偏移量(boxOffset)+拖拽工作区宽度,也就是边长(shadowBoardSquare)
- 上边距=拖拽工作区距离可视区域上边距(offsetLeft)+裁剪面板偏移量(boxOffset)
- 下边距=拖拽工作区距离可视区域上边距(offsetLeft)+裁剪面板偏移量(boxOffset)+拖拽工作区高度,也就是边长(shadowBoardSquare)
知道这四个边界的距离后,看下面的拖拽超出范围判断逻辑,是不是感觉很清晰。 最后要做的事情都交给setEleAttr
函数了。setEleAttr
会完成拖拽点的位移设置,裁剪图片效果设置,更新最新的css代码展示值。
// 鼠标移动回调函数
const moveCb = (e) => {
const ele = document.querySelector('.is-dragging');
let { offsetX, offsetY } = extractTranslateOffset(ele);
const { clientX, clientY, movementX, movementY } = e;
offsetX += movementX; //距上次鼠标位置的X偏移量
offsetY += movementY; //距上次鼠标位置的X偏移量
// console.log({ offsetX, offsetY });
// 防止拖拽点移动过快,飞出容器
// 超出左侧边界
if (clientX < (offsetLeft + boxOffset)) {
offsetX = 0;
}
// 超出右侧边界
else if (clientX >= (offsetLeft + shadowBoardSquare + boxOffset)) {
offsetX = shadowBoardSquare;
}
// 超出上侧边界
if (clientY < (offsetTop + boxOffset)) {
offsetY = 0;
}
// 超出下侧边界
else if (clientY > (offsetTop + shadowBoardSquare + boxOffset)) {
offsetY = shadowBoardSquare;
}
const index = pointIds.findIndex(id => id === ele.id);
setEleAttr(index, ele, { offsetX, offsetY });
};
最后再说说mouseup
事件。主要做了两件事: 第一清除绑定在document
元素上的mousemove
事件,避免鼠标左键释放的时候,还白白消耗浏览器性能,毕竟mousemove
事件触发的频率太高了。 第二 移除拖拽点上的动态样式,让拖拽点的外观展示恢复正常。
// 鼠标左键抬起时 清除mousemove事件和拖拽裁剪点动态样式
document.addEventListener('mouseup', function (e) {
coords.forEach(({ id }) => {
document.querySelector(`#${id}`).classList.remove('is-dragging');
})
document.removeEventListener('mousemove', moveCb);
});
拖拽这部分的完整代码如下:
// 裁剪正方形的边长
const shadowBoardSquare = 280;
// 拖拽工作区
const playGroundEle = document.querySelector('#play-ground-ele');
// offsetLeft,offsetTop是相对于body元素的左上角偏移
const { offsetLeft, offsetTop, clientWidth } = playGroundEle;
// 拖拽工作区和拖拽蒙层之间的偏移
const boxOffset = (clientWidth - shadowBoardSquare) / 2;
// 拖拽点容器
const pointsEle = document.querySelector('#points-ele');
// 裁剪板
const clipboardEle = document.querySelector('#clip-board-ele');
// 展示css代码
const showCodeEle = document.querySelector('#show-code-ele');
const pointIds = coords.map((item) => item.id);
// 鼠标左键按下时 添加mousemove事件和拖拽裁剪点动态样式
pointsEle.addEventListener('mousedown', function (e) {
// 至关重要,禁止浏览器默认行为,不然mouseup事件有时不响应,导致无法清除mousemove事件
e.preventDefault();
// 只监听鼠标左键按下事件
if (e.button !== 0) return false;
// 为了提高事件绑定性能,采用的是事件委托的写法,所以要判断触发事件的元素
if (pointIds.includes(e.target.id)) {
e.target.classList.add('is-dragging');
document.addEventListener('mousemove', moveCb);
}
});
// 鼠标左键抬起时 清除mousemove事件和拖拽裁剪点动态样式
document.addEventListener('mouseup', function (e) {
coords.forEach(({ id }) => {
document.querySelector(`#${id}`).classList.remove('is-dragging');
})
document.removeEventListener('mousemove', moveCb);
});
// 鼠标移动回调函数
const moveCb = (e) => {
const ele = document.querySelector('.is-dragging');
let { offsetX, offsetY } = extractTranslateOffset(ele);
const { clientX, clientY, movementX, movementY } = e;
offsetX += movementX; //距上次鼠标位置的X偏移量
offsetY += movementY; //距上次鼠标位置的X偏移量
// console.log({ offsetX, offsetY });
// 防止拖拽点移动过快,飞出容器
// 超出左侧边界
if (clientX < (offsetLeft + boxOffset)) {
offsetX = 0;
}
// 超出右侧边界
else if (clientX >= (offsetLeft + shadowBoardSquare)) {
offsetX = shadowBoardSquare;
}
// 超出上侧边界
if (clientY < (offsetTop + boxOffset)) {
offsetY = 0;
}
// 超出下侧边界
else if (clientY > (offsetTop + shadowBoardSquare)) {
offsetY = shadowBoardSquare;
}
const index = pointIds.findIndex(id => id === ele.id);
setEleAttr(index, ele, { offsetX, offsetY });
};
/**
* 将transform:translate(x,y)中的x,y偏移量提取出来
* */
function extractTranslateOffset(ele) {
// 获取位移矩阵
const matrix = window.getComputedStyle(ele, null).transform.split(',');
// console.log(matrix);
// matrix的值是这样的:['matrix(1', ' 0', ' 0', ' 1', ' 56', ' 0)'];
// 后面两个值是x和y的偏移量
let offsetX = parseFloat(matrix[4]);
// parseFloat可以将形如'56)'这样的字符串转换成数字56
let offsetY = parseFloat(matrix[5]);
return { offsetX, offsetY };
}
还有一点可能要补充一下, 就是如何获取拖拽点上一次的偏移位置ele.style.transform = translate(${offsetX}px,${offsetY}px)
; 用ele.style.cssText
获取不到将transform属性值,要用window.getComputedStyle
函数才行。
体验优化
为了复制拖拽裁剪图片结束后的css clip-path
的属性值更方便一些,我们在css代码展示区添加一个复制按钮,实现一个复制功能。
<!-- css代码区域 -->
<footer class="show-css-code">
<pre id="show-code-ele" class="code-area"></pre>
<button id="copy-ele">复制</button>
</footer>
实现复制功能的核心是document.execCommand()
方法, document.execCommand()
可操作可编辑区域的内容。什么是可编辑区域,就是input,textarea这些。document.execCommand
有三个参数,操作成功返回true,否则返回false。
boolean = document.execCommand(commandName, showDefaultUI, valueArgument)
参数名 | 含义 |
---|---|
commandName | 表示命令名称,比如: copy , cut ,paste ,redo ,delete 等 |
showDefaultUI | MDN上说这个参数的作用是是否展示用户界面,一般情况下都是 false , 可是我发现设置成true在Chrome浏览器上也看不出任何效果; |
valueArgument | 有些命令需要额外的参数(如insertImage 需要提供插入 image 的 url),一般用不到,默认值是null; |
完整的复制功能代码如下:
// 复制裁剪图片属性值
document.querySelector('#copy-ele').addEventListener('click', copy);
function copy() {
var copyInput = document.createElement("input");
var text = clipboardEle.style.cssText;
copyInput.setAttribute("value", text);
document.body.appendChild(copyInput);
copyInput.select();
document.execCommand("copy");
document.body.removeChild(copyInput);
}
再加一个提示效果。用alert太生硬,要手动去点击确认。我们自己实现一个简易版的toast功能,效果如下:
toast实现代码如下:
<!-- css代码区域 -->
<footer class="show-css-code">
<div id="toast-ele" class="toast">复制成功</div>
</footer>
const toastEle=document.querySelector('#toast-ele');
toastEle.classList.add('show');
setTimeout(()=>{
toastEle.classList.remove('show');
},1000);
.toast{
position: absolute;
top:-40px;
left:50%;
transform:translate(-50%);
z-index: 9999;
background: #0a0a0a;
color: white;
padding: 8px;
border-radius: 10px;
box-shadow: 0 0 2px rgba(16, 10, 9, 0.15);
transition:.3s;
cursor: default;
user-select: none;
opacity: 0;
font-size: 12px;
&.show{
opacity: 1;
}
}
结语
clippy网站展示的裁剪图片类型极其丰富,本文只挑了其中的一个特例, 说明其核心实现原理, 如果大家对css clip-path图形裁剪可视化也感兴趣的话,可以点击这里下载完整代码学习。
转载自:https://juejin.cn/post/7228805676901695525