likes
comments
collection
share

css clip-path裁剪可视化工具真给力,我也想造这个轮子

作者站长头像
站长
· 阅读数 46

背景

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

css clip-path裁剪可视化工具真给力,我也想造这个轮子

效果演示

给大家演示一下, 用自己开发的clip-path可视化工具,如何实现项目中的直角卡片效果。拖动左上,右上,右下三个图片裁剪拖拽点,就能裁剪出直角梯形效果,然后点击css代码展示区域的复制按钮,就能将css设置属性复制到剪切板,用Ctrl+V大法复制到你的代码中,搞定,今天可以偷得半日闲了。

css clip-path裁剪可视化工具真给力,我也想造这个轮子

可视化裁剪实现思路

clippy网站的轮子何其多,全部都造, 迫于专注力有限,造不过来。所以就得挑一个有代表性的,选择项目中用到的那个直角梯形图片裁剪轮子,再合适不过了。选定了目标之后,撸起袖子,开干。

第一步 先把静态页面画出来

clippy官网直角梯形图片裁剪的界面展示效果, 页面分为上下两部分,上面是拖拽工作区,下方是代码展示区。上下两部分水平居中,所以页面容器应该采用弹性列布局。代码展示区域比较简单, 难点是拖拽工作区的功能实现。拖拽工作区可进一步划分成三部分。从前往后看,最前面的是四个拖拽点区域,中间是裁剪面板区域,最后面是带有边框的蒙层面板。

css clip-path裁剪可视化工具真给力,我也想造这个轮子

基于上面的分析,页面的结构为:

<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)函数转换一下。裁剪面板每个属性值设置成百分比的好处是通用性更强, 不管裁剪面板是什么尺寸, 复制出来的百分比属性值都可以直接使用,不需转换。

css clip-path裁剪可视化工具真给力,我也想造这个轮子

  • 设置裁剪面板的初始裁剪效果

设置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表示命令名称,比如: copycut,paste,redo,delete 等
showDefaultUIMDN上说这个参数的作用是是否展示用户界面,一般情况下都是 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功能,效果如下:

css clip-path裁剪可视化工具真给力,我也想造这个轮子

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
评论
请登录