宣传物料定制化场景的实现与落地

背景

门店宣传物料是指门店在日常营运时所需的包含且不局限于(宣传单页、横幅、贴纸、海报等)。在营销策略中我们较为熟知千人千面,当然宣传物料也是如此,我们需要满足不同店铺的营销、宣传需求来做宣传物料的定制化方案。

需求分析

那么针对这一需求,我们需要先对此进行问题拆分:

  • 定制宣传物料包含哪些元素?
  • 其中哪些元素是需要加盟商定制化的?
  • 如何确保加盟商填写的信息是符合设计规范的?
  • 加盟商以何种形式填报定制信息?

首先我们来看一个宣传单页都可能包含哪些元素

业务流程及技术架构设计

整体的实现思路可梳理以下几点:

  1. 通过 canvas 设计物料模版,一个模版通过 JSONSchema 来进行描述,其中包含固定元素及自定义元素的属性信息。
  2. 将模版与物料进行关联,统一下发到门店侧。
  3. 门店可通过详情页面对自定义的内容进行填写提报需求单,同时可以生成预览图片。
  4. 需求单提报后,设计师可在订单管理中心对自定义内容的渲染图进行审核修改,最终生成设计终稿。

整体业务流程及系统架构如下图

通过流程梳理,可以看到大部分的功能实现在创作端的编辑器以及用户端的定制信息收集

编辑器设计

为方便下边文章的介绍,我们先定义几个名词

  1. 画布【Canvas】:这个没啥好讲的
  2. 海报【Poster】:可以理解为工作区域,因为需要在画布中开辟一个矩形区域来承载设计元素。当然不止于海报、横幅等等。这里统一称为海报
  3. 偏移值:其中包含画布和海报都有自己的偏移值,画布相对于 document,海报相对于画布记为 [left, top]

我们可以先根据下面这张图对编辑器进行拆解,其中标注的一些属性对后续的计算有关键作用 以上是对编辑器的拆解,由于原生的 canvas api 用起来很繁琐,市面上有很多优秀的 canvas 库通过封装,可以让我们通过操作对象的方式来进行绘图,简化了 canvas 的使用复杂度。下边介绍几个流行的库。

canvas 技术选型

针对实际业务场景,搜集了市面上几个流行的 canvas 库进行对比

相关数据 上手难度 功能 扩展性 适用场景
Fabric.js star 28.2k
fork 3.5k
Issues 355
size 23.9MB 文档和社区支持度相对较高
需要自行处理交互事件 基于对象模型,支持丰富的图形操作和事件处理
内置强大的事件处理机制 支持插件和扩展
支持导入和导出 SVG
支持 JSON schema 适合开发复杂的图形编辑器和设计工具
Konvajs star 11.1k
fork 898
Issues 34
size 1.41MB 相比 Fabric.js,文档和社区支持较少 基于对象的图形模型,支持图层管理和事件处理 支持插件和扩展
支持 JSON schema 适合高性能应用,如游戏、数据可视化等
Excalidraw star 77.5k
fork 7.1k
Issues 1413
size 45.3M 开箱即用
API 设计简单,但拓展性较差 实时协作
手绘风格 功能较固定,且支持支持 JSON schema
适合快速原型设计、团队协作、教育和演示

就实际业务场景来讲,我们需要满足素材库拓展要求以及设计师的复杂图形编辑需求,同时需要保证项目能稳定推进。结合上述对比,最终选择社区活跃度较高的 Fabricjs 作为最终方案。

画布组件 & 海报初始化

Fabricjs提供了丰富的API,可以让我们更灵活的处理画布、事件监听以及对元素对象操作等。在我们的业务场景中,我们需要在画布中开辟一个区域来承载一张海报所包含的元素。那么就需要把关注点从整个画布到一个特定区域,我把它叫做工作区 或者 Poster。所以在编辑器中定义一个新的实体 Poster ,它继承于Fabric.Canvas 。并且添加了一些自己的特性,关系如下图 首先我们来看下画布组件是如何使用 Poster 构造器的,构造函数接受两个参数,第一个为 canvas 元素 id,第二个为 CanvasOptions,并拓展了默认宽高的属性

tsx 复制代码
const poster = new Poster('canvas', {
  posterDefaultWidth: posterState.defaultWidth,
  posterDefaultHeight: posterState.defaultHeight,
});

return (
  <div className="container design-stage-grid">
    <canvas id="canvas" />
  </div>
);

Poster 内部首先调用父类 Canvas 构造函数创建 canvas 实例,并且传入默认参数,注意这里的 container?.height 与 container?.width 是指创建画布的尺寸而不是海报的尺寸,而 container 是指 的父级容器。

typescript 复制代码
constructor(el: string, options?: IPosterOptions) {
  const container = document.getElementById(el)?.parentElement?.getBoundingClientRect();

  super(el, {
    fireRightClick: true, // 启用右键,button 的数字为 3
    stopContextMenu: true, // 禁止默认右键菜单
    controlsAboveOverlay: true, // 超出 clipPath 后仍然展示控制条
    height: container?.height,
    width: container?.width,
    allowTouchScrolling: true,
    centeredRotation: true,
    preserveObjectStacking: true,
    //   ...options,
  });
  
  this.defaultWidth = options?.posterDefaultWidth || 400;
  this.defaultHeight = options?.posterDefaultHeight || 1000;
  this.posterId = options?.posterId || 'poster_workspace'
  
  this.initPoster(); // 初始化工作区
  initWheelEvent(this); // 初始化滚轮的交互事件
}

initPoster 方法定义为 Poster 的私有函数,主要功能为创建一个默认宽高的矩形,并且计算初始缩放比(目的大尺寸的海报能完整展现在视口范围内),核心逻辑如下

typescript 复制代码
const defaultZoom =
  Math.min(this.width / this.defaultWidth, this.height / this.defaultHeight) - 0.01;
const left = (this.width - this.defaultWidth) / 2;
const top = (this.height - this.defaultHeight) / 2;
const [centerX, centerY] = [this.width / 2, this.height / 2];
const workspace = new Rect({
  left,
  top,
  width: this.defaultWidth,
  height: this.defaultHeight,
  hasControls: false, // 不响应事件
  fill: '#fff',
  id: this.posterId,
});
this.add(workspace);
this.zoomToPoint({ x: centerX, y: centerY } as Point, defaultZoom);

事件处理

画布事件

Poster 继承了 Fabric.Canvas 的事件系统,首先我们需要通过触控板上的指控事件来控制画布动作,所以通过监听 mouse:wheel 事件来处理设计师操作交互,按常规的操作习惯在触控板中的事件主要分为

  • 滑动/滚动(两指平滑)
  • 缩放(两指向两侧移动)

所以,我们只需要区分这两类事件就可以实现该交互了。在贴代码前先介绍几个关键属性,这些都属性在 W3C WheelEvent 文档中都有详细介绍

metaKey 与 ctrlKey 两者都是属于键盘属性, 其中 metaKey 在 MAC 键盘上,表示 Command 键(⌘),在 Windows 键盘上,表示 Windows 键(⊞)。ctrlKey 则代表 ctrl 键。两个值为 Boolean 类型,其中为 true 代表按下。

那么在鼠标事件中为什么会介绍这两个键盘属性呢? 经过测试,当两指向两侧移动时 ctrlKey 为 true 其他情况为 false。所以我们可以通过这个属性来判断操作是缩放 OR 滑动。 接下来我们还需要判断事件是缩放中的"缩"还是"放"

deltaY 与 deltaX 这两个属于滚轮事件中的属性, 其中 deltaY 代表滚轮沿 Y 轴滚动的偏移值~~(向上为正数,向下为负数)~~,deltaX 代表滚轮在 X 轴方向的偏移值

好了,介绍完这几个属性后我们就可以来实现画布的交互事件了,滑动事件相对简单我们可以从它先开始

滑动事件

滑动事件是指我们在画布范围内可以任意移动海报容器,在这一事件中我们需要 Fabric 中的一个概念 【矩阵】viewportTransform,它以一个一维数组的形式表示 包含六个元素 [a, b, c, d, e, f]

  • ad:缩放因子(横向和纵向缩放)
  • bc:倾斜因子(横向和纵向倾斜)
  • ef:平移因子(横向和纵向平移)

在这个事件中我们只需要在原有的偏移值基础上对滚轮 X 与 Y 轴的偏移进行计算,在重新设置就可以了

typescript 复制代码
poster.on('mouse:wheel', function (opt) {
  const { deltaY, deltaX } = opt.e;
  const zoom = poster.getZoom();
  poster.viewportTransform[4] -= deltaX / zoom;
  poster.viewportTransform[5] -= deltaY / zoom;
  poster.setViewportTransform(poster.viewportTransform);
})
缩放事件

缩放事件中我们需要关注 deltaY 这个属性,刚才有讲过这个字段为滚轮在 Y 轴滚动的偏移值,所以基于这个属性我们可以计算新的缩放比例 首先考虑到缩放的平滑性我们需要将其缩小 1%

typescript 复制代码
poster.on('mouse:wheel', function (opt) {
  const { deltaY, metaKey, ctrlKey, offsetX, offsetY } = opt.e;

  if (metaKey || ctrlKey) {
    // 获取当前缩放比例
    const zoom = poster.getZoom();
    
    const sign = Math.sign(deltaY);
    const MAX_STEP = 0.1 * 100;
    const absDelta = Math.abs(deltaY);
    let delta = deltaY;
    if (absDelta > MAX_STEP) {
      delta = MAX_STEP * sign;
    }
    const newZoom = zoom - delta / 100;
    
    // 限制最小比例
    newZoom > 0.2 && poster.zoomToPoint({ x: offsetX, y: offsetY } as Point, newZoom);
  }
})

素材面板拖拽事件

素材拖拽功能的实践,使用的 HTML 的原生的拖拽 API,只需要在节点上绑定draggable 属性,就可以实现基础的拖拽功能,代码如下

tsx 复制代码
<div className="libs-container">
  {libs.map((el) => (
    <div draggable onDragEnd={(e) => onDragEnd(e, el)} className="lib-card" key={el.id}>
      <div>{el.icon}</div>
      <div>{el.text}</div>
    </div>
  ))}
</div>

这里的 libs 就是素材列表,每个素材包含一个 create 函数,用来创建 Fabric 的元素对象,我们拿一个文本对象来举例:

typescript 复制代码
export const create = (options) => {
  return new Textbox('默认文字', {
    custom: {},
    fontSize: 32,
    ...options,
  });
};

但是由于我们前面将工作区定义在画布中的指定区域,所以还需要进行一些计算,来确保释放的区域为有效的,计算临界点之后的工作就很简单了包括

  1. 通过坐标、缩放比例以及 id 等参数,调用 create 方法创建一个元素对象,这里的 id 需要保证唯一性,我这里通过一个开源库nanoid 来生成。这个 id 的目的是后续门店侧的定制信息收集表单做关联。
  2. 接下来就是将这个对象添加到画布并且选中它(这里选中后会触发对象的选中事件,这里就会关联该对象的设置表单的展示)
typescript 复制代码
const { width, zoomX, height, left, top } = posterState.poster.getPosterInfo() as any;
const { clientX, clientY } = e;
const right = +left + width * zoomX;
const bottom = +top + height * zoomX;
const canvasOffset = posterState.poster._offset;

const drawLeft = clientX - canvasOffset.left;
const drawTop = clientY - canvasOffset.top;
if (drawLeft > left && drawLeft < right && drawTop < bottom && drawTop > top) {
  // 创建元素对象
  const com = item.create({
    left: drawLeft,
    top: drawTop,
    scaleX: zoomX,
    scaleY: zoomX,
    id: nanoid(10),
  });
  posterState.poster.add(com);
  posterState.poster.setActiveObject(com);
}

对象事件

这个事件的监听在设置面板组件中 选中事件其实就是监听 Fabric 的 selection:created 事件,通过这个事件我们可以获取到选中元素对象的集合,我暂时先考虑选中单个元素的情况。

typescript 复制代码
posterState.poster.on('selection:created', (event) => {
  const [selected] = event.selected as any;

  // 获取基础的设置表单字段
  const properties = posterState.poster.getBaseSchemaProperties(selected);
  setJsonData(properties || {});

  setFormData(selected);
});

获取到设置表单的 properties 后,就可以通过 Formily 的 FormProvider 以及SchemaField 进行渲染了,这样就完成了根据选中的元素对象渲染不同设置表单的逻辑。 那么表单的值该如何处理呢? 在 Fabric 的元素对象中包含很多属性,我们仍然以文本对象举例:

tsx 复制代码
{
  "id": "Z0RXzBepX1",
  "type": "Textbox",
  "fontSize": 32,
  "fontWeight": "normal",
  "fontFamily": "Times New Roman",
  "fontStyle": "normal",
  "lineHeight": 1.16,
  "text": "默认文字",
  "fill": "rgb(0,0,0)",
}

当然它的属性不止如此,我只是挑了几个比较好解释的出来😆,更多详细的可以在官方文档中自行查阅。 这样一列出来大家就很好理解了,我们只要把他的可变属性跳出来,结合 schema 表单的设计不就好了吗,比如这样: 那么又来新的问题了,表单数据的变化该如何响应到画布上? 其实我们在初始化 Poster 的时候还做了一个事情,就是创建一个 setting 的表单对象,同时我们通过effects 来实现监听数据的变化,然后去重绘画布,我来拿字体加载的例子来说:

typescript 复制代码
Poster.settingForm = createForm({
  effects: () => {
    onFieldChange('fontFamily', ({ value: fontOption }) => {
        const activeObject = this.getActiveObject();
        const { value, label } = fontOption; // 字体名称及资源地址
        if (value && label) {
          // 实例化字体
          const font = new FontFace(label, `url(${value})`);
          font.load().then((res) => {
            // 字体加载完成
            document.fonts.add(font);
            if (activeObject) {
              // 设置字体
              activeObject.set('fontFamily', res.family);
              // 重绘画布
              this.renderAll();
            }
          });
        }
      });
  }
})

这样是不是就很清晰了!好了截止到此编辑器的大部分功能的实现思路就介绍完毕了,最后还有一个门店定制需求表单的实现思路,下边我简单说下。

关联小程序端的定制需求收集表单

上边有讲到我们在创建一个元素对象的时候会生成一个唯一 id,它起到的作用就是作为定制需求表单中的 Field Key

  • 将元素对象的唯一 id 作为定制需求表单中的 Field Key。
  • 表单填写完成后,将其 value 与 Fabric 中的元素对象中的渲染属性,如"text"进行替换。
  • 最后通过 Fabric 中的 loadFromJSON 方法进行最后的图片合成

总结

该项目整体借助两个优秀的开源库 Fabricjs 与 Formily,以及公司现有的供应链体系。实现了定制物料从创建到下发门店,从门店的定制需求的收集到合成制作送到门店手中。在满足了门店多元化的营销场景的同时也提高了总部的运营效率。 当然项目仍然在不断的进行完善优化,后续还有很多场景需求、问题等着我们去解决。比如丰富素材库、高分辨率图片的合成效率、以及大文件资源管理上的优化等等... 最后希望本篇文章能够为读者带来一些业务架构以及技术实践上的参考。

相关推荐
彭世瑜17 分钟前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund40418 分钟前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish19 分钟前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five20 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序20 分钟前
vue3 封装request请求
java·前端·typescript·vue
临枫54121 分钟前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
前端每日三省22 分钟前
面试题-TS(八):什么是装饰器(decorators)?如何在 TypeScript 中使用它们?
开发语言·前端·javascript
小刺猬_98522 分钟前
(超详细)数组方法 ——— splice( )
前端·javascript·typescript
渊兮兮24 分钟前
Vue3 + TypeScript +动画,实现动态登陆页面
前端·javascript·css·typescript·动画
鑫宝Code24 分钟前
【TS】TypeScript中的接口(Interface):对象类型的强大工具
前端·javascript·typescript