背景介绍我们知道,为了提高企业研发效率,快速响应客户需求,现在很多企业都在进行数字化转型,不仅仅是大厂(阿里、wordFestival、腾讯、百度)都在做低代码可视化,很多中小企业也在做。具有视觉低代码相关技术背景的程序员也越来越受到关注。最近一直在做数据可视化和lowcode/nocode相关的项目。结合自己的工作经验和对lowcode/nocode的探索,我也写了一系列低代码可视化构建文章。今天继续分享可视化相关的内容——可视化图像编辑器。在分享的过程中,我会以我最近写的一个开源项目米兔为例,仔细拆解它的实现过程。Mitu主要是用来辅助H5编辑器H5-Dooring进行图片处理。您还可以轻松地基于它进行二次开发和扩展,成为功能更强大的图片编辑器。文末附上github地址和demo地址,方便大家学习和体验。接下来,我将介绍和分析这款开源照片编辑器Mitu。项目介绍以上是图片编辑器的部分演示效果。我们可以通过拖拽重组的方式快速生成我们想要的图片,也可以将图片保存为模板,方便以后重复使用。在项目开发之前,我也设计了一个简单的原型,保证自己的开发方向不会偏离。高效阅读学习:可视化编辑器工程搭建与技术选型图库设计属性编辑器设计自定义图元控制器实现预览功能实现图片保存功能实现模板保存实现导入模板功能实现可视化图片编辑器后期策划下面废话不多说开始我们的技术实现。技术实现项目建设和技术选型编辑器的实现思路与技术栈无关。这里我使用React来实现。当然,如果你更喜欢Vue或者sveltejs,也没问题。项目整体技术选型如下:umi可扩展企业级前端应用框架React+Typescriptantd前端组件库fabric一个可以简化Canvas编程的库localStorage本地数据存储当然还有很多项目实施中的细节和思路,我会一一为大家介绍。如果你对fabric库不熟悉也不用担心,我会通过具体功能的实现来给大家介绍一下这个库。在介绍下面的内容之前,我们先来安装fabric并初始化一个canvas。Yarnaddfabric初始化画布:import{fabric}from"fabric";import{nanoid}from'nanoid';import{useEffect,useState,useRef}from'react';exportdefaultfunctionIndexPage(){constcanvasRef=useRef(null);useEffect(()=>{canvasRef.current=newfabric.Canvas('canvas');//创建文本元素constshape=newfabric.IText(nanoid(8),{text:'H5-Dooring',width:60,height:60,fill:'#06c',left:30,top:30})//向画布中插入文本元素canvasRef.current.add(shape);//设置画布的背景色canvasRef.current.backgroundColor='rgba(255,255,255,1)';})return}这样我们就创建了一个canvas,并在canvas中插入一段文字编辑可拖动文字,如下:图库设计作为图片编辑器,为了提高使用的灵活性,我们也需要提供一些基本的图形,方便我们设计图片,所以我在编辑器中添加了一个图库:主要为Text、图片、直线、矩形、圆形、三角形、箭头、马赛克,当然你可以根据需要添加更多的基本图元。我们可以单击图像库中的任何元素将其插入到画布中。这是使用fabric的add方法。当然,fabric也内置了很多基础图形,我们可以在文档中参考。为了让图形插入更加封装,我定义了图形的基本schema结构:constbaseShapeConfig={IText:{text:'H5-Dooring',width:60,height:60,fill:'#06c'},三角形:{width:100,height:100,fill:'#06c'},圆:{radius:50,fill:'#06c'},矩形:{width:60,height:60,fill:'#06c'},Line:{width:100,height:1,fill:'#06c'},Arrow:{},Image:{},Mask:{}}这样插入图形的方法可以写成如下:typeElementType='IText'|'Triangle'|'Circle'|'Rect'|'Line'|'Image'|'Arrow'|'Mask'constinsertShape=(type:ElementType)=>{shape=newfabric[type]({...baseShapeConfig[type],left:size[0]/3,top:size[1]/3})canvasRef.current.add(shape);}我们后面添加图形的时候只需要定义schema,但需要注意fabric创建图形的方式并不都是统一的。我们需要对具体图片的创建进行特殊判断,比如直线路径:if(type==='Line'){shape=newfabric.Path('M00L1000',{stroke:'#ccc',strokeWidth:2,objectCaching:false,left:size[0]/3,top:size[1]/3})}当然我们也可以使用switch来区别处理不同的情况。这样,我们就实现了一个基本的图片库。属性编辑器设计属性编辑器主要用于配置图形属性,如填充颜色、描边颜色、描边宽度。目前我主要定义这三个维度。也可以基于这个Editable属性继续展开更多,类似于H5-Dooring的组件属性配置面板。我们可以在编辑器右侧的属性编辑区控制图形的属性。由于只有3个属性,我只是对它们进行了硬编码。您也可以使用动态渲染来实现它。需要注意的是,我们怎么知道我们选择了哪个组件呢?幸运的是,fabric提供了一系列的API来帮助我们更好的控制元素对象。这里我们使用getActiveObject方法获取当前选中的元素。具体实现代码如下://...//定义基本属性const[attrs,setAttrs]=useState({fill:'#0066cc',stroke:'',strokeWidth:0,})//更新所选元素constupdateAttr=(type:'fill'|'stroke'|'strokeWidth'|'imgUrl',val:string|number)=>{setAttrs({...attrs,[type]:val})//获取当前选中的元素对象constobj=canvasRef.current.getActiveObject()//设置元素属性obj.set({...attrs})//重新渲染canvasRef.current.renderAll();}的样式实现属性编辑器这里就不一一介绍了,都是比较基础的,先来看看编辑项的基本结构:strokewidth:updateAttr('strokeWidth',v)}/>自定义原语控制器实现因为fabric默认不提供删除按钮和逻辑,我们需要扩展两次,fabric只是提供了自定义的扩展方法,然后我们一起自定义一个删除按钮,实现删除逻辑。具体实现代码如下://删除按钮constdeleteIcon="data:image/svg+xml,%3C%3Fxmlversion='1.0'encoding='utf-8'%3F%3E%3C!DOCTYPEsvgPUBLIC'-//W3C//DTDSVG1.1//EN''http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvgversion='1.1'id='Ebene_1'xmlns='http://www.w3.org/2000/svg'xmlns:xlink='http://www.w3.org/1999/xlink'x='0px'y='0px'width='595.275px'height='595.275px'viewBox='200215230470'xml:space='preserve'%3E%3Ccirclestyle='fill:%23F44336;'cx='299.76'cy='439.067'r='218.516'/%3E%3Cg%3E%3Crectx='267.162'y='307.978'transform='matrix(0.7071-0.70710.70710.7071-222.6202340.6915)'style='fill:white;'width='65.545'height='262.18'/%3E%3Crectx='266.988'y='308.153'transform='matrix(0.70710.7071-0.70710.7071398.3889-83.3116)'style='fill:white;'width='65.544'height='262.179'/%3E%3C/g%3E%3C/svg%3E";//删除方法functiondeleteObject(eventData,transform){consttarget=transform.target;constcanvas=target.canvas;canvas.remove(target);canvas.requestRenderAll();}//rendericonfunctionrender我con(ctx,left,top,styleOverride,fabricObject){constsize=this.cornerSize;ctx.save();ctx.translate(left,top);ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle));ctx.drawImage(img,-size/2,-size/2,size,size);ctx.restore();}//全局添加删除按钮fabric.Object.prototype.controls.deleteControl=newfabric.Control({x:0.5,y:-0.5,offsetY:-32,//自定义与元素的偏移距离,也可以定义offsetXcursorStyle:'pointer',mouseUpHandler:deleteObject,render:renderIcon,cornerSize:24});所以我们可以在实现了自定义元素控件之后,我们也可以通过类似的方式来实现自定义控件效果如下:previewfunction实现预览功能,我主要是使用原生canvas的toDataURL方法生成base64数据,然后将其分配给img标签。还有一个需要注意的细节就是如果我们在预览之前画布上还有选中的元素,控制点也会被截取,如下:这对用户体验非常不好,我们需要看一个纯粹的我的解决方法是取消选中预览前画布所有元素的状态。您可以使用fabric实例的discardActiveObject()方法来停用活动状态,然后更新画布。具体实现逻辑如下://1.取消画布所有元素的选中状态canvasRef.current.discardActiveObject()canvasRef.current.renderAll();//2.将当前画布转换为图片的base64地址constimg=document.getElementById("canvas");constsrc=(imgasHTMLCanvasElement).toDataURL("image/png");//3.设置元素url,显示预览弹窗setImgUrl(src)setIsShow(true)预览效果展示:保存图片的功能其实和预览的功能很相似,唯一不同的是我们需要将图片下载到本地,那么我主要是使用纯前端的方式实现图片下载,大家也可以使用自己熟悉的前端下载方案,然后贴出我的方案实现:functiondownload(url:string,filename:string,cb?:Function){returnfetch(url).then(res=>res.blob().then(blob=>{leta=document.createElement('a');leturl=window.URL.createObjectURL(blob);a.href=url;a.download=filename;a.click();window.URL.revokeObjectURL(url);cb&&cb()}))}主要用到了window的URL对象的createObjectURL和revokeObjectURL方法,两年前我也曾在我的文章中分享过相应的实现,有兴趣的可以参考一下。下载后的效果如下:模板保存实现在图片编辑器的设计过程中,我们还需要考虑保存用户的资源。比如比较好的图片可以保存为模板,下次再用,所以我还是实现了简单的模板保存和使用功能。先来看一下效果:我们在demo中可以看到,保存为模板后,会自动同步到左侧的模板列表中,下次创建时可以直接导入模板进行二次创建.下面是实现逻辑图:从上图我们可以发现,我们保存模板不仅仅是为了保存图片,还需要保存图片对应的jsonschema数据。之所以保存jsonschema,是为了保证当用户切换到对应的模板时,模板文件的每个元素都能恢复,类似于我们最熟悉的PSD源文件。Fabric提供了方法toDatalessJSON()来序列化画布。在保存模板的时候,我们只需要将序列化后的json和图片一起保存即可。这样便于加工。我暂时存放在localStorage,大家也可以使用大容量本地化存储。对于indexedDB的解决方案,我之前也封装了基于indexedDB的开箱即用的缓存库xdb,大家可以直接使用。xdb|基于promise封装且支持过期时间的开箱即用的indexedDB缓存库保存模板具体实现如下:constandleSaveTpl=()=>{constval=tplNameRef.current.state.valueconstjson=canvasRef.current。toDatalessJSON()constid=nanoid(8)//保存jsonconsttpls=JSON.parse(localStorage.getItem('tpls')||"{}")tpls[id]={json,t:val};localStorage.setItem('tpls',JSON.stringify(tpls))//保存图像canvasRef.current.discardActiveObject()canvasRef.current.renderAll()constimgUrl=getImgUrl()consttplImgs=JSON.parse(localStorage.getItem('tplImgs')||"{}")tplImgs[id]=imgUrllocalStorage.setItem('tplImgs',JSON.stringify(tplImgs))//更新模板列表setTpls((prev:any)=>[...prev,{id,t:val}])setIsTplShow(false)}importtemplate函数实现importtemplate的本质是反序列化JsonSchema。在研究fabric的过程中,发现它可以直接加载json渲染图形序列,所以我们可以直接将上面保存的json直接加载到Canvas中://1.加载canvasRef.current.clear()之前清空画布;//2.重置画布背景色canvasRef.current.backgroundColor='rgba(255,255,255,1)';//3.渲染jsoncanvasRef.current.loadFromJSON(tpls[id].json,canvasRef.current.renderAll.bind(canvasRef.current))然后我们可以根据保存的模板列表,动态切换模板:这个图片编辑器我已经在github上开源了,方便后期规划,大家可以根据本文开发更强大的图片编辑器