从零开始实现一个图片裁剪工具 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
cyrbuzz
V2EX    分享创造

从零开始实现一个图片裁剪工具

  •  
  •   cyrbuzz
    HuberTRoy 2021-10-29 15:49:09 +08:00 2478 次点击
    这是一个创建于 1441 天前的主题,其中的信息可能已经有所发展或是发生改变。

    不久前做了一个图片裁剪的需求,开源组件裁剪部分完全够用,框的自定义有些不足,于是自己参考着从 0 实现了一下,发现并不需要过多的 Canvas 知识,也并没有想象中的难= =。

    从一个刮刮乐说起

    在实现裁剪工具之前先来看一个刮刮乐的实现,实现刮刮乐的思路很简单,底层一张图上面是一个灰色蒙版,鼠标(点击后)经过的地方将部分灰色蒙版去除。

    去除用 CSS 其实并不困难,难得是任意坐标下连贯的去除,这点对于 CSS 来说并不好实现。这里会自然而然的想到用 Canvas 去做:

    一些前置的 Canvas 理论知识:

    save 和 restore 用来存储画笔状态和还原画笔状态,因为我们操作的同一支画笔,不存储和还原可能会出现难以调试的 BUG 。 fillStyle 可以设置当前画笔的颜色,支持透明度。 fillRect 可以绘制一个矩形,x,y,width,height 。 clearRect 可以以一个矩形擦除某块区域,x,y,width,height 。 
    1. 绘制底图:
    <style> #canvas { background: url(./gouzi.jpeg); } </style> <canvas id="canvas"></canvas> <script> let canvas = document.querySelector("#canvas"); let ctx = canvas.getContext("2d"); canvas.width = 500; canvas.height = 500; </script> 
    1. 绘制一层蒙版:
    const drawModal = () => { ctx.save(); ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0, 0, 500, 500); ctx.restore(); }; 

    1.jpg

    1. 鼠标移动时我们将对应的块擦除:
    const clearRect = (x, y, w, h) => { ctx.save(); ctx.clearRect(x, y, w, h); ctx.restore(); }; canvas.addEventListener("mousemove", (e) => { const { offsetX, offsetY } = e; clearRect(offsetX, offsetY, 30, 30); }); 

    这样便可以实现一个超简易版的刮刮乐。

    img

    刮刮乐进阶

    蒙版,鼠标擦除我们都实现了,美中不足的是擦除的块只能是以矩形的方式展示,无法支持任意形状。

    而 Canvas 本身没有提供其他擦除的方法,一种有趣(和有用)的曲线救国的方式是用混合(CSS 中的 mix-blend-mode 也支持设置混合),在所有的混合模式中,会发现destination-out刚好符合我们的需求,在此种混合模式下,已存在的图像只保留没有重叠的部分,刚好是一个擦除的效果。

    写起来并不困难,只需要将上面 3.中的 clearRect 换成 fillRect:

    const clearRect = (x, y, w, h) => { ctx.save(); ctx.globalCompositeOperation = "destination-out"; ctx.fillStyle = "rgba(0,0,0)"; ctx.fillRect(x, y, w, h); ctx.restore(); }; 

    这里 fillStyle 并不重要,新的图像只会留下形状,不会留下内容。

    这样用 fill 实现了一个和 clear 一致的擦除效果,现在只需要将 fillRect 换成 drawImage ,就可以实现任意图形的擦除。

    const clearRect = (x, y, w, h) => { ctx.save(); ctx.globalCompositeOperation = "destination-out"; ctx.drawImage(img, x, y, w, h); ctx.restore(); }; 

    可以看到绘制出了猛犸状的擦除,不要问我为什么选猛犸,因为猛犸它不上BAN

    实现图片裁剪

    裁剪工具的实现思路

    实现之前整理一波需求:

    1. 绘制一层黑白透明底。
    2. 用一个 Canvas 来绘制将要裁剪的图片,这里需要注意的点是图片可能会超过画板的大小,需要做一个缩放处理。
    3. 用另一个 Canvas 绘制灰色蒙版以及挖洞,这里用一个 Canvas 加载底图和绘制蒙版这些也是可以的,用上面提到的混合,我还是分成两个了更加符合直觉。
    4. 用 HTML 来绘制裁剪框的边框以及处理交互事件。

    黑白透明底

    透明的底一般都会用灰白相间的格子表示,可以直接放一张图,不过这并不能凸显出我们的逼格,用 CSS 实现也并不困难,这里用到 background 的相关属性。

    我们知道 background 可以写渐变色,而且可以写很多个,background-size 和 background-position 也都可以单独指定每一个 background 的状态。

    首先指定两个同样的渐变色:

    background: linear-gradient( 45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 25% ), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 25%); 

    linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 25%)这一段渐变可以生成一个在右上角和左下角是灰色,中间透明的渐变。

    将 background 的大小设置为想要的块大小,这里我设置为 8px:

    background-size: 8px 8px; 

    因为 background 默认铺不满会重复的特性,会看到一张满是三角的格子。

    img

    最后我们将第二个(第一个也行,只要符合预期即可)background 的 x 和 y 都移动4px,上一个三角与下一个三角会组合为一整个正方形,不断重复就生成了一个透明格子板。

    background-position: 0 0, 4px 4px; 

    img

    缩放和绘制图片

    上面提到绘制图片时我们需要将图片缩放至合适的画板大小,canvas 里并没有类似object-fit: contain的方便属性给我们用,只能自己写了,基本思路也很简单,图片的宽和高都必须小于画板的宽高,如果其中有一个不符合就继续缩小,直到都符合:

    const shrinkImage = ({ imageWidth, imageHeight, width, height, base = 1 }) => { // 根据 height 和 width 返回一个合适的 imageWidth 和 imageHeight 。 if (imageWidth / base < width && imageHeight / base < height) { return { imageWidth: imageWidth / base, imageHeight: imageHeight / base, }; } return shrinkImage({ imageWidth, imageHeight, width, height, base: base + 0.1, }); }; 

    一个简单的尾递归版本,除数的步幅可以看需求增减。

    drawImage 有很多参数,这里我们都用一遍:

    let baseWidth = 500; let baseHeight = 500; let img = new Image(); let canvasOne= document.querySelector("#canvas-one"); let basePaintParams = { baseOffsetX: 0, baseOffsetY: 0, w: 0, h: 0, }; img.src = "./gouzi.jpeg"; img.Onload= () => { let { imageWidth, imageHeight } = shrinkImage({ imageWidth: img.width, imageHeight: img.height, width: baseWidth, height: baseHeight, }); ctxOne.drawImage( img, 0, 0, img.width, img.height, (baseWidth - imageWidth) / 2, (baseHeight - imageHeight) / 2, imageWidth, imageHeight ); basePaintParams = { baseOffsetX: (baseWidth - imageWidth) / 2, baseOffsetY: (baseHeight - imageHeight) / 2, w: imageWidth, h: imageHeight, }; }; 

    drawImage 中,第一个参数是原图像,二三个参数标识原图的剪裁 xy,四五个参数表示原图的剪裁宽高,六七个参数表示绘制到 Canvas 中的 xy 这里用 canvas 的宽高减去图像绘制的宽高再除以 2 得到的 xy 是居中的 xy ,八和九则是绘制到 Canvas 中的宽高。

    这里声明了一个basePaintParams的变量,现在用不到,后面绘制裁剪框时会用到,这里做一个初始化,默认和绘制的图像一致的 xy 宽高。

    img

    MDN 参考:drawImage

    绘制蒙版和挖空

    蒙版与挖空的具体实现可以用刮刮乐里的实现,这里实现一个矩形裁剪框,混合和 clearRect 都可以。

    蒙版:

    let canvasTwo = document.querySelector("#canvas-two"); const paintModal = () => { ctxTwo.clearRect(0, 0, baseWidth, baseHeight); ctxTwo.fillStyle = "rgba(0,0,0,0.5)"; ctxTwo.fillRect(0, 0, baseWidth, baseHeight); }; paintModal(); 

    img

    挖空:

    const clipModal = () => { ctxTwo.save(); ctxTwo.clearRect( basePaintParams.baseOffsetX, basePaintParams.baseOffsetY, basePaintParams.w, basePaintParams.h ); ctxTwo.restore(); }; // 要等到 img onload 之后再绘制,或者提供一个初始化的参数。 clipModal(); 

    img

    这里用 HTML+CSS 实现起来也并不困难,不必非要用 Canvas 。

    绘制裁剪框

    具体的裁剪框我们用 HTML 来实现,绑定事件这些还是 HTML 来的舒服,常规形状的裁剪框 HTML 可以很轻松的编写,奇奇怪怪的形状也可以用clip-path来实现,暂时先不趟 Canvas 的浑水了。

    <style> .cropper-clip { position: absolute; cursor: all-scroll; border: 1px solid rgb(30, 158, 251); } .dot { width: 10px; height: 10px; border-radius: 50%; background: #1e9efb; position: absolute; } .topleft { top: -5px; left: -5px; cursor: nwse-resize; } .topright { top: -5px; right: -5px; cursor: nesw-resize; } .topcenter { top: -5px; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .bottomcenter { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .bottomleft { bottom: -5px; left: -5px; cursor: nesw-resize; } .bottomright { bottom: -5px; right: -5px; cursor: nwse-resize; } .leftcenter { top: 50%; transform: translateY(-50%); left: -5px; cursor: ew-resize; } .rightcenter { top: 50%; transform: translateY(-50%); right: -5px; cursor: ew-resize; } </style> <div class="cropper-clip"> <div class="dot topleft"></div> <div class="dot topright"></div> <div class="dot topcenter"></div> <div class="dot bottomleft"></div> <div class="dot bottomcenter"></div> <div class="dot bottomright"></div> <div class="dot leftcenter"></div> <div class="dot rightcenter"></div> </div> 

    一组很容易写的框,一共八个点,分布在四周,框的位置和大小在设置蒙版和挖空时一起给上:

    const drawClipDiv = () => { let cropperClip = document.querySelector(".cropper-clip"); cropperClip.style.width = `${Math.abs(basePaintParams.w)}px`; cropperClip.style.height = `${Math.abs(basePaintParams.h)}px`; cropperClip.style.left = `${basePaintParams.baseOffsetX}px`; cropperClip.style.top = `${basePaintParams.baseOffsetY}px`; }; 

    Math.abs用来适配改变裁剪框大小时越过线的情况,如果不做可以不加(本文没有实现这个)。

    现在已经将裁剪的骨骼描绘完整了,只要后续改变basePaintParams中的坐标和大小参数,重新绘制蒙版挖空以及裁剪框即可。

    img

    裁剪框的移动与伸缩

    上面我们已经绘制出了基本骨骼,最后只要让裁剪框可以跟随鼠标动起来就可以了。

    首先我们现在的 HTML 结构是这样的:

    <div class="cropper"> <div class="cropper-clip"> <div class="dot topleft"></div> <div class="dot topright"></div> <div class="dot topcenter"></div> <div class="dot bottomleft"></div> <div class="dot bottomcenter"></div> <div class="dot bottomright"></div> <div class="dot leftcenter"></div> <div class="dot rightcenter"></div> </div> </div> 

    我们给cropper注册一个mousemove事件,这样比较容易处理。

    裁剪框体和保存某个点按下,需要额外的变量标识。

    let dotType = ""; let dotDown = false; let clipDown = false; const registerEvents = () => { const register = (_class) => { let node = document.querySelector(`.${_class}`); node.addEventListener("mousedown", (e) => { dotDown = true; dotType = _class; }); node.addEventListener("mouseup", () => { dotDown = false; }); }; register("topcenter"); register("bottomcenter"); register("leftcenter"); register("rightcenter"); register("bottomright"); register("bottomleft"); register("topright"); register("topleft"); let clip = document.querySelector(".cropper-clip"); clip.addEventListener("mousedown", () => { clipDown = true; }); clip.addEventListener("mouseup", () => { clipDown = false; }); }; 

    核心mousemove的事件整体思路从一条边出发,只要确定了上下和左右的运动策略,其他点进行组合即可:

    const cardMouseMove = (e) => { if (!dotDown && !clipDown) { return; } const { offsetX, offsetY } = e; const topYChange = () => { if (e.target === canvasTwo) { if (offsetY < basePaintParams.baseOffsetY) { basePaintParams.h += basePaintParams.baseOffsetY - offsetY; basePaintParams.baseOffsetY = offsetY; } } else if (e.target === clip) { basePaintParams.h -= offsetY; basePaintParams.baseOffsetY += offsetY; } }; const bottomYChange = () => { if (e.target === canvasTwo) { if (offsetY > basePaintParams.baseOffsetY) { basePaintParams.h = offsetY - basePaintParams.baseOffsetY; } } else if (e.target === clip) { basePaintParams.h = offsetY; } }; const leftXChange = () => { if (e.target === canvasTwo) { if (offsetX < basePaintParams.baseOffsetX) { basePaintParams.w += basePaintParams.baseOffsetX - offsetX; basePaintParams.baseOffsetX = offsetX; } } else if (e.target === clip) { basePaintParams.w -= offsetX; basePaintParams.baseOffsetX += offsetX; } }; const rightXChange = () => { if (e.target === canvasTwo) { if (offsetX > basePaintParams.baseOffsetX) { basePaintParams.w = offsetX - basePaintParams.baseOffsetX; } } else if (e.target === clip) { basePaintParams.w = offsetX; } }; }; 

    这里需要注意的是,我们注册的事件在父节点上,所以事件的 target 有可能是我们绘制的蒙版(在放大的时候),也有可能是裁剪框(缩小的时候),这时候的offset会有所不同。

    之后我们只需要根据 8 个点所期望改变的 xy 调用不同的方法即可:

    const dotRunMap = { topcenter: () => { topYChange(); }, bottomcenter: () => { bottomYChange(); }, leftcenter: () => { leftXChange(); }, rightcenter: () => { rightXChange(); }, bottomright: () => { rightXChange(); bottomYChange(); }, bottomleft: () => { leftXChange(); bottomYChange(); }, topright: () => { rightXChange(); topYChange(); }, topleft: () => { leftXChange(); topYChange(); }, }; if (dotDown) { dotRunMap[dotType](); } // 如果没有点按下,说明是在移动,这里没有做边缘限制,会出圈。 if (clipDown && !dotDown) { basePaintParams.baseOffsetX = basePaintParams.baseOffsetX + e.movementX; basePaintParams.baseOffsetY = basePaintParams.baseOffsetY + e.movementY; } drawClipDiv(); paintModal(); clipModal(); 

    不要忘了重新绘制裁剪框,蒙版和挖空。

    img

    预览与保存

    预览需要用到另一个 Canvas 来接盘,这里主要用到getImageDataputImageData,参数大部分都一样,需要注意的坑点是如果是个跨域图片,Canvas 默认会认为受到污染,这时需要设置一下原来 image 请求时的跨域设置,更具体可以参考一下这篇文章

    <canvas id="clipImage" />; const save = () => { let bgCtx = canvasOne; let imageData = bgCtx.getImageData( basePaintParams.w < 0 ? basePaintParams.baseOffsetX + basePaintParams.w : basePaintParams.baseOffsetX, basePaintParams.h < 0 ? basePaintParams.baseOffsetY + basePaintParams.h : basePaintParams.baseOffsetY, Math.abs(basePaintParams.w), Math.abs(basePaintParams.h) ); let targetCanvas = clipImage.getContext("2d"); clipImage.width = basePaintParams.w; clipImage.height = basePaintParams.h; targetCanvas.putImageData( imageData, 0, 0, 0, 0, basePaintParams.w, basePaintParams.h ); }; 

    保存上是一个比较常规的做法,把 Canvas 先转成 dataURL 的 base64 形式,然后再转换为 Blob ,下载这个 Blob 即可。

    const dataURItoBlob = async (url) => await (await fetch(url)).blob(); const saveData = (function () { const a = document.createElement("a"); document.body.appendChild(a); return function (blob, fileName) { let url = window.URL.createObjectURL(blob); a.href = url; a.download = fileName; a.click(); window.URL.revokeObjectURL(url); }; })(); const exportImg = async () => { let MIME_TYPE = "image/png"; let imgURL = clipImage.toDataURL(MIME_TYPE); let res = await dataURItoBlob(imgURL); saveData(res, "data.png"); }; 

    总结

    这样一个基础功能完备的裁剪工具就完成啦,核心裁剪部分代码并不多,用到的 Canvas 内容也不多,整体很适合来熟悉一波 Canvas ,有了骨骼之后其他的功能都可以沿着这个脉络进行扩展。

    这里封装了一个 Vue3 的版本,有需要可以在Github上找到~。

    img

    3 条回复    2021-11-01 10:16:48 +08:00
    pigzzz
        1
    pigzzz  
       2021-10-30 10:27:43 +08:00   1
    24match
        2
    24match  
       2021-10-30 14:00:49 +08:00
    后端人看了表示脑壳疼
    cyrbuzz
        3
    cyrbuzz  
    OP
       2021-11-01 10:16:48 +08:00
    @24match

    捏一下小萌柴就不疼了,哈哈。
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     5718 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 29ms UTC 06:22 PVG 14:22 LAX 23:22 JFK 02:22
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86