最近在调研海报编辑器 想要做到对一张图片进行添加文本 图片之类的操作 我就去调研了一些开源的海报编辑器。其实在web上关于图片的编辑器还是很多的,种类也很丰富,比如 miniPaint基本复刻了 ps,基于 farbic.js的 Pintura.和 tui.image-editor,基于 Konva的 polotno等等。
那我们的现阶段是实现一个轻量级图文编辑器,实现一些特定的交互操作和属性配置就可以了,那我这里主要调研的是一个简单、功能齐全、插件化架构,适合二次开发网页版的海报编辑器。我调研了一些方案,最后选定了基于开源的海报编辑器讯排设计做二次开发,下面也会展示部分此项目中的代码。
github地址:https://github.com/palxiao/poster-design
分享给大家
捋捋功能
如何生产出海报展示给用户?
海报生产和制作,模板化,海报的绘制和渲染。
大致操作流程:
基于模板或上传底图,进行图文编辑,可添加图片和文字,文字可修改字体 颜色 大小。同时可控制元素的缩放旋转、层级移动、删除和复制。最后基于模板和元素,导出最终的图片。
海报内容元素抽象为两类:
基础元素:线条 几何图形 图片 文本等
业务元素:具有业务属性的元素 例如二维码 头像等等
模板与素材库:
编辑器内提供丰富的海报模板,涵盖不同行业、不同风格,用户可根据需求选择合适的模板作为基础。也可以自己上传PSD文件,等待解析完成后开始编辑。
素材库提供大量的图片、图标、背景等素材,用户可轻松添加到海报中,丰富视觉效果。
这里就涉及到一个模版的解析,也就是psd文件解析。
讯排的psd解析方案是基于开源的psd.js实现的。psd.js是一款Photoshop文件解析库,支持解析 photoshop cc2019 及更早版本的所有主要元素,包括图层,蒙版、文本、调整图层、形状等。支持NodeJS和浏览器环境。
文件解析: psd.js通过创建一个树状结构来表示PSD文件中的图层和文件夹,从而解析PSD文件。它能够提取文档结构、尺寸、图层/文件夹的位置、名称、可见性、不透明度等重要数据. 图层数据处理: psd.js允许开发者遍历PSD文件的图层树,逐个将图层数据转换为Fabric.js对象,实现图层数据的导入 跨平台兼容性: 作为一个纯JavaScript库,psd.js可以在浏览器环境和Node.js环境中运行,这意味着它可以无缝集成到Web应用程序或桌面应用程序中。 说明: *psd.js 和 Fabric.js 可以结合使用: psd.js负责解析PSD文件并提取图层数据,而Fabric.js则用于在网页上渲染和操作这些图层数据,两者结合可以实现从PSD设计到Web界面的转换。
核心编辑器模块:
功能上比如:复制粘贴元素、组合成组、拆组、添加文字、图片、拖拽、裁剪、快速改变层级等等。
利用HTML5, CSS3和JavaScript开发。特别是Canvas元素和一些canvas的扩展库例如fabricjs等,用于作为海报的画布。
SVG & Canvas混合渲染:结合SVG的矢量图形优势和Canvas的像素级操作,保证了图像质量的同时,实现了丰富的动态效果
画布区域交互设计
拖拽:HTML5的Drag and Drop API,使得元素的添加与移动变得直观易用。也有很多封装的库可以用比如vuedraggable。就是mousedown、mousemove和mouseup事件的结合使用:在组件上按下鼠标后,记录组件当前位置,也就是x、y坐标(对应的是css中的left和top);每次鼠标移动时用当前最新的xy坐标减去最开始的xy坐标,计算出移动的距离,然后更新组件位置;鼠标抬起时结束移动。
工具栏:一些样式调整,元素的选择,模块切换等等
元素层级:展示当前画布内元素的层级信息,更加直观且方便快速调整。
放大缩小、撤销重做
数据结构
模版化的原理是定义了一套描述海报模板的DSL,渲染引擎可以渲染并解析DSL。
我们的内容元素和素材作为最小单位,用可配置化的数据结构来描述海报。在讯排中 是以JSON数据作为DSL描述海报,保存工程数据,将工程数据传入渲染内核就可以进行视图的预览、编辑、导出等操作。
图层管理:在这里,引入图层概念,创建Layer类管理单个元素的位置、大小等信息。后面我会重点说一下这个。层级数据格式案例:
数据&视图
数据和画布中的视图做了双向绑定,所以数据改变后只需要调用方法触发视图的更新。为了节约渲染开销,大部分都采用了手动触发的方式来通知视图的更新,做很多数据改动后才会去触发一次 update。
// 修改坐标 layer.x = 100; layer.y = 200; // 通知视图更新 store.update();
绘制 保存
方式有很多种 我这简单介绍一下。
前端:
使用HTML元素制作和渲染海报,由html2canvas转换为图片,开发效率很高,但是清晰度很低而且性能有一些差。
使用Canvas开发绘制海报,由Canvas生成图片,但是存在一定的性能瓶颈,比如在客户端,弱网环境 / 低性能安卓机上耗时较长。
服务端:
用Puppeteer 或 Phantomjs截图生成图片:先把海报在网页中渲染出来,对页面进行截图
用node-canvas绘制并导出图片 :先把海报在网页中渲染出来,将网页转换成canvas后再导出图片。
在讯排中:
在服务端,他们使用 puppeteer 启动无头浏览器,利用 Chrome 打开绘制页,并往其 BOM 中注入广播通知方法。
在绘制页面请求保存页面中元素集合的json信息,将页面呈现出来。通过一系列方法判断元素是否加载完成,
一旦整个页面以及资源都加载完成则调用 window 下的广播通知开始截图,这样就完成了整个图片生成操作的闭环。
*Puppeteer 是一个 Node 库,它提供了一个高级的 API 来通过 DevTools 协议控制 Chrome 或 Chromium。Puppeteer 默认以无头模式运行,但也可以配置为运行“有头”模式。它常用于网页自动化测试、生成页面截图或PDF、爬取 SPA(单页应用)并生成预渲染内容等。
扩展与集成:
集成图片处理库:如使用Apache Commons Imaging进行图片处理,实现图片的裁剪、旋转、滤镜等效果
插件化架构:如果按照硬编码的方式实现,那么组件将会错综复杂,相互紧密耦合,修改一个微小功能都要梳理众多组件的影响范围。有可能出现一些问题比如:导入逻辑出现过多业务处理代码。多个组件的订阅事件出现相互依赖。也是我们未来会重点扩展的方向。
社交媒体分享:集成小红书、微信,等社交媒体API,实现一键分享海报到社交媒体。
到这里基本就是现在市面上的海报编辑器的重点功能了。
图层管理
下面我给大家介绍其中的图层管理这个点。图层面板主要是控制组件的显示/隐藏、不同组件的层级关系以及点击选中。
在canvas中:
canvas 元素提供了一个绘图环境,但是它本身并没有提供方法来控制绘图元素的层级顺序。
在canvas中,默认「后创建的对象」在z轴上高于「先创建的对象」。通常,这意味着你需要在每次重绘画布时,按照正确的顺序重新绘制每个图形元素。
例如,你可能会先绘制位于底部的元素,然后是中间的元素,最后是顶部的元素。这样,最后绘制的元素会覆盖先前绘制的元素,从而在视觉上创建了层级效果。
一般情况下,我们不会一开始就想好所有对象的创建顺序,然后依次创建它们。所以需要灵活得调整对象之间的层叠顺序。
如果他要实现层级,需要自己管理绘图顺序。这时候可以选择一些框架协助做这件事。
Fabric.js,是一个可以让 HTML5 Canvas 开发变得简单的框架 。它是一种基于 Canvas 元素的 可交互 对象模型,也是一个 SVG 到 Canvas 的解析器(让SVG 渲染到 Canvas 上)。另外,提供了许多额外的功能来处理图形对象,包括层级控制。
fabric提供了一些方法使我们可以方便的更改元素的层级: toTop: 置于顶层,调用canvas的bringToFront方法 up: 向上一层,调用canvas的bringForward方法 down: 向下一层,调用canvas的sendBackwards方法 toBottom: 置于底层,调用canvas的sendToBack方法
但是我发现讯排没有使用canvas和fabric去操作。那他是如何做的呢?
正常情况下,我们会选择使用z-index来控制层级。但是我发现他并没有使用z-index,而是利用了层叠领域黄金准则的第二条。
层叠领域黄金准则: 1、谁大谁上:当具有明显的层叠水平标示的时候,如识别的z-indx值,在同一个层叠上下文领域,层叠水平值大的那一个覆盖小的那一个。 2、后来居上:当元素的层叠水平一致、层叠顺序相同的时候,在DOM流中处于后面的元素会覆盖前面的元素。
这样做的好处就是不仅操作方便,也不用增加额外冗余代码。就是计算起来我感觉还挺麻烦的。
我看他的具体实现
他创建Layer类管理单个元素的位置、大小等信息,并使用列表管理所有图层,通知所有注册的监听器改变。
Layer类通常是一个在图形编辑软件中用来表示图层的对象。在海报编辑器这样的应用中,Layer类可以封装一个图层的所有属性和行为,使得图层的管理变得更加简单和直观。
使用 Layer 类:
定义Layer类
首先,你需要定义Layer类,包括它的属性和方法。这通常在你的项目中的一个单独的文件或模块里完成。
// Layer.js class Layer { constructor(id, name) { this.id = id; this.name = name; //名称 this.visible = true; //是否展示 this.opacity = 1; //透明度 this.position = { x: 0, y: 0 }; //位置 this.size = { width: 0, height: 0 }; //大小 this.rotation = 0; //旋转 this.elements = []; //包含元素 } }
创建Layer实例
当需要一个新的图层时,创建Layer类的实例。
// main.js import { Layer } from './Layer.js'; const backgroundLayer = new Layer('layer1', 'Background'); const foregroundLayer = new Layer('layer2', 'Foreground');
管理图层
在你的编辑器或应用中,你可能需要一个管理器来处理所有图层的逻辑,比如添加、删除、重排序图层等。
// LayerManager.js class LayerManager { constructor() { this.layers = []; } addLayer(layer) { this.layers.push(layer); } removeLayer(layerId) { this.layers = this.layers.filter(layer => layer.id !== layerId); } // 其他管理方法... }
渲染图层
你需要一个渲染函数来将图层绘制到画布上。这个函数会遍历所有图层,并根据每个图层的属性来绘制它们的内容。
// Renderer.js function renderLayer(layer, context) { if (!layer.visible) return; context.save(); context.globalAlpha = layer.opacity; context.translate(layer.position.x, layer.position.y); context.rotate(layer.rotation * Math.PI / 180); layer.elements.forEach(element => { // 根据元素类型绘制元素 }); context.restore(); } //渲染全部的图层 function renderAllLayers(layers, canvasContext) { layers.forEach(layer => { renderLayer(layer, canvasContext); }); }
与用户界面交互
在你的用户界面中,你需要提供工具让用户能够与图层交互,比如拖动图层来改变顺序,或者改变图层的属性。
Bring to Front Send to Back
// main.js document.getElementById('canvas').addEventListener('click', (event) => { // 根据点击事件处理图层交互 }); // 假设你有一个函数来更新画布 function updateCanvas() { const canvas = document.getElementById('canvas'); const context = canvas.getContext('2d'); renderAllLayers(layerManager.layers, context); }
保存和加载图层状态
你可能需要保存和加载图层的状态,以便用户可以保存他们的工作并在以后加载。
// 保存图层状态 function saveLayers() { const layersState = layerManager.layers.map(layer => ({ id: layer.id, name: layer.name, visible: layer.visible, opacity: layer.opacity, position: { x: layer.position.x, y: layer.position.y }, size: { width: layer.size.width, height: layer.size.height }, rotation: layer.rotation, elements: layer.elements.map(element => ({ /* 元素的保存逻辑 */ })) })); // 将layersState保存到本地存储或服务器 } // 加载图层状态 function loadLayers() { // 从本地存储或服务器加载layersState const layersState = /* 加载逻辑 */; layersState.forEach(layerState => { const layer = new Layer(layerState.id, layerState.name);