自定义封面功能

需求背景

自定义封面经过了多版本的迭代:

1.脑洞文作品签约、更新速度快,如果都由UI出封面工作量会很大。封面库里的数量也有限,导致现在很多作品封面区别度低,辨识度不高。希望能有一个自定义封面的工具,让作者可以自己创建封面。

2.书名字体单一,需要增加特效文字、主副标题功能,丰富封面

3.番茄作者用奇妙的编辑封面功能制作了封面后,上传到番茄,侵害了七猫的封面版权,需要在前台封面编辑器内增加水印。

4.有些书名带有标点符号导致封面排版不美观,需要对封面上的标点做处理。

主要功能

  • 可选择封面模板,选择后在画布上根据一定规则创建出封面模板(底图+书名+作者名)
  • 可选择特效文字列表,选择后,会根据每个特效字demo图关联的特效字配置美化书名的样式(字体,字号,颜色,投影,描边,素材,填充物等)
  • 文字样式支持编辑,单个文字样式也支持修改,双击书名,选择文字后选择属性,使用fabric.js的setSelectionStyles来设置属性
  • 文字位置支持拖动,这个是fabric.js自带的功能,作者可以自定义书名位置
  • 画布大小可以根据屏幕大小适配,默认是以适配屏幕大小的比例来缩放画布
  • 可拖动主副标题,按照一定规则,给书名设置字号和换行
  • 保存生成封面
效果图
效果图

参考案例

方案调研

数据统计为2023.8.8

fabricjs konva pixi tui-image-editor
简介 Fabric.js是一个可以简化Canvas程序编写的库,适用于开发小型应用。 支持: * 在Canvas上创建、填充图形(包括图片、文字、规则图形和复杂路径组成图形)。 * 给图形填充渐变颜色。 * 组合图形(包括组合图形、图形文字、图片等)。 * 设置图形动画集用户交互。 * 生成JSON, SVG数据等。 * 生成Canvas对象自带拖拉拽功能。 * 添加文本框 Konva 是一个 HTML5 Canvas JavaScript 框架,支持桌面和移动应用程序的高性能动画、过渡、节点嵌套、分层、过滤、缓存、事件处理等等。 Pixi是一个非常快的2D sprite渲染引擎。它可以帮助你显示、动画和管理交互式图形,这样你就可以轻松地使用JavaScript和其他HTML5技术制作游戏和应用程序。 pixi.js不是对canvas的封装,它是使用WebGL来绘制2D,WebGL是使用显卡绘制甚至参与计算的,性能自然比Canvas高出很多,pixi也可以选择使用canvas来绘制2D。 这是一个强大的图片编辑插件,主要有图片放大,裁切,旋转,绘制,标记,添加文字,模糊等功能,具体的可以看 官网,或者github
github地址 https://github.com/fabricjs/fabric.js https://github.com/konvajs/konva https://github.com/pixijs/pixijs https://github.com/nhn/tui.image-editor
start数量 25.5k 9.6k 40k 6.2k
fork数量 3.3k 826 4.8k 1.2k
issue情况 276 79 152 211
更新频率 基本上每周都有 大约一个月一次 基本上每周都有 大约一个月一次
官方文档 http://fabricjs.com/ https://konvajs.org/docs/ https://pixijs.com/ https://pixijs.huashengweilai.com/ https://ui.toast.com/tui-image-editor
参考案例 vue-fabric-editorreact-design-editor新版蓝湖 polotno-studio 暂无 暂无
适用场景 适用于开发小型应用 适合复杂应用 适合游戏开发 适合处理图片,不适合本项目初期开发
学习成本 可参考案例较多,学习成本一般,容易上手 可直接参考案例较少,学习成本较高,对ts的支持比较高,架构设计更加灵活 学习成本较高,需要具备一定的图形设计能力 学习成本一般,容易上手
兼容性 ie11 ❌ Edge Legacy ❌ Firefox ✅ Safari(version >= 10.1) ✅ Opera✅ Chrome✅ Konva 适用于所有现代移动和桌面浏览器。对于较旧的浏览器,您可能需要填充来弥补缺失的功能。例如IE11 需要引入polyfills来支持 Chrome ✅ ie6+ ✅ Edge ✅ safari ✅ Firefox ✅ Chrome ✅ ie10+ ✅ Edge ✅ safari ✅ Firefox ✅
总结 fabric.js更适合我们的项目,因为它社区更加成熟,稳定,我们之前并没有开发过类似的图形编辑器,一个成熟稳定的社区更方便我们解决问题和避坑,常用功能封装齐全,可参考案例较多,上手和使用成本不高。 与fabric.js功能差不多,代码包比较小,性能较好一些,但是生态不如fabricjs强。 使用pixi可编辑文本需要手写实现,拖拽移动缩放也要自己手写。它更适合用于创建游戏、动画和交互式应用程序。如果我们要用pixi来实现自定义封面会增加很多时间成本、学习成本、困难程度,并且自定义封面操作功能并不是很复杂。 功能虽然很强大,但是我们这个需求主要用到了添加文字功能。不适合我们项目。

目录结构

q-edit-picture
├── README.md
├── components
│   ├── attribute-pic.vue
│   ├── attribute.vue
│   ├── special-effect.vue
│   ├── ...
│   ├── text-edit-tools.vue
│   ├── title-select.vue
│   ├── water-mark.vue
│   └── zoom.vue
├── core
│   ├── editFont.js
│   ├── index.js
│   ├── initControls.js
│   ├── initEmptyCanvas.js
│   ├── initHotKeys.js
│   ├── initWorkspace.js
│   ├── loadData.js
│   ├── loadDataOld.js
│   └── mainSubTitle.js
├── data
│   └── rule-config.js
├── directive
│   └── lazyload.js
├── fonts
│   ├── iconfont.ttf
│   ├── iconfont.woff
│   └── iconfont.woff2
├── images
│   ├── ...
│   └── white-miao-default-cover.png
├── index.js
├── index.vue
├── mixins
│   └── index.js
├── styles
│   ├── base.scss
│   ├── common.scss
│   ├── edit-pic-color-variable.scss
│   ├── index.scss
│   ├── mixin.scss
│   └── reset.scss
└── utils
    ├── eventHandler.js
    └── tools.js

组件规划

组件规划和设计的思想主要是参考了vue-fabric-editor 的项目

实现原理

在index.vue文件使用fabric.js创建了画布对象,实例化了编辑器对象,并通过provide将画布对象,fabric对象,监听事件向下传递

provide: {
    canvas,
    fabric,
    event
},
mounted() {
    this.canvas = new fabric.Canvas('editCanvas', {
        width: this.editorPictureData.coverWidth,
        height: this.editorPictureData.coverHeight,
        preserveObjectStacking: true
    });
    canvas.c = this.canvas;
    event.init(canvas.c);
    const _config = {
        bookName: this.bookName,
        ...this.editorPictureData
    };
    canvas.editor = new Editor(canvas.c, event, _config);
},

通过mixins,注入依赖,让子组件可以通过引入mixins来独立开发对应的功能,各个子组件之间解耦。

export default {
    inject: ['canvas', 'fabric', 'event'],
    data() {
        return {
            mixinSelectMode: '', // one | multiple
            mixinSelectOneType: '', // i-text | group ...
        };
    },
    created() {
        this.event.on('on_select_one', (e) => {
            this.mixinSelectMode = 'one';
        });
        this.event.on('on_select_cancel', this.select_cancel); // 选择取消
    },
    // mixin_方法名
    methods: {
        // 未选中
        select_cancel(){
            this.mixinSelectMode = '';
        }
    }
};


在index.js 核心文件中封装Editor对象,挂载通用封装的方法到Editor对象上,可以暴露给外部使用,也可以供组件内部使用。

核心方法

初始化

初始化控制器样式

controlStyle: {
    borderColor: '#FAE950',
    cornerColor: '#FAE950', // 激活状态角落图标的填充颜色
    cornerStrokeColor: '#FAE950', // 激活状态角落图标的边框颜色
    borderOpacityWhenMoving: 1,
    borderScaleFactor: 2,
    cornerSize: 12,
    cornerStyle: 'circle', // rect,circle
    centeredScaling: false, // 角落放大缩小是否是以图形中心为放大原点
    centeredRotation: true, // 旋转按钮旋转是否是左上角为圆心旋转
    transparentCorners: false, // 激活状态角落的图标是否透明
    rotatingPointOffset: 20, // 旋转距旋转体的距离
    lockUniScaling: false, // 只显示四角的操作
    hasRotatingPoint: false, // 是否显示旋转按钮
    showLock: true // 控制是否显示lock
}
fabric.Object.prototype.set(controlStyle);

初始化快捷键

快捷键可以提高操作效率,比如组合/拆分组合、复制、删除等,只需要将快捷键事件和Editor的功能方法做绑定即可快速实现快捷键功能。

快捷键监听有现成的工具库hotkeys-js,只需要绑定事件即可。

// 快捷键功能
import {js_tools_change_size} from '@/q-edit-picture/utils/tools';
import hotkeys from 'hotkeys-js';
// import { v4 as uuid } from 'uuid';

const _keyNames = {
    lrdu: 'left,right,down,up', // 左右上下
    backspace: 'backspace', // backspace键盘
    ctrlz: 'ctrl+z',
    ctrlc: 'ctrl+c',
    ctrlv: 'ctrl+v',
    big: 'ctrl+=, command+=, ctrl++, command++',
    small: 'ctrl+-, command+-'
};

function init_hotkeys(canvas, that) {
    // 放大
    hotkeys(_keyNames.big, (e) => {
        e.preventDefault();
    });
    // 缩小
    hotkeys(_keyNames.small, (e) => {
        e.preventDefault();
    });
    // 对所有按键监听
    hotkeys('*', function(e){
        const activeObject = canvas.getActiveObjects();
        }
    });
}

export default init_hotkeys;
export { _keyNames, hotkeys };

监听拖拽

计算拖动元素的可拖动范围,并限制拖动范围

event.on('on_mouse_move', function(opt) {
      const { limitContentConfig } = config;
      const _activeObject = canvas && canvas.getActiveObjects()[0];
      if (_isDragging && _activeObject) {
          limitContentConfig.isLimit && draw_in_canvas(canvas, _activeObject, limitContentConfig);
      }
  });

监听舞台尺寸变化

使用ResizeObserver,来监听,计算缩放比例

_initResizeObserve(canvas) {
    const _workspaceEl = document.querySelector('#js-editor-picture');
    const _resizeObserver = new ResizeObserver(
        js_tools_throttle((entries) => {
            let _zoomCounter = 1;
            const {lARMargin, tABMargin, minSize, maxSize, coverWidth, coverHeight} = this.config;
            const {width, height} = entries[0].contentRect;
            const _width = width - lARMargin;
            const _height = height - tABMargin;
            if (_width / coverWidth > _height / coverHeight) {
                _zoomCounter = _height / coverHeight;
            } else {
                _zoomCounter = _width / coverWidth;
            }
            // 计算缩放的尺寸
            if (_zoomCounter < minSize) {
                this.zoomCounter = minSize;
            } else if (_zoomCounter > maxSize) {
                this.zoomCounter = maxSize;
            } else {
                this.zoomCounter = _zoomCounter;
            }
            this.initZoomCounter = this.zoomCounter;
            this.handle_zoom(this.zoomCounter);
            this.event.emit('on_canvas_size_change', this.zoomCounter);
        }, 500, true)
    );
    _resizeObserver.observe(_workspaceEl);
}

创建

创建封面

create_cover({cover_url, bookName, bookNameTop, fill, authorName, tplId}) {
  return new Promise((resolve, reject) => {
      // 1. 清空画布
      this.event.emit('on_edit_loading', true, 'fontFamilyText');
      this.clear();
      // 2. 加载封面图片
      js_tools_load_img(cover_url).then((img) => {
          // 创建图片对象
          const _imgInstance = new fabric.Image(img, {
              lockScalingX: false, // 水平是否可以缩放
              lockScalingY: false, // 垂直方向是否可以缩放
              lockMovementX: false, // 水平方向是否可以移动
              lockMovementY: false, // 垂直方向是否可以移动,
              selectable: false,
              hasControls: false,
              tplId
          });
          const {standardFont} = this.config;
          // 3. 创建书名
          const _bookName = new fabric.Textbox(bookName, {
              fontSize: standardFont.bookNameFontSize,
              fill,
              top: bookNameTop,
              type: 'bookName',
              textAlign: 'center',
              id: uuid(),
              fontFamily: standardFont.fontFamily,
              fontId: standardFont.fontId,
              editable: true,
              lineHeight: 1,
              lockMovementX: true // 水平方向禁止移动
          });
          // 设置输入框 可以选中 但是不能修改文字和换行
          _bookName.initHiddenTextarea = js_tools_init_hidden_textarea.bind(_bookName);
          // 创建这个对象,只是为了计算书名宽度
          const _innerAuthorName = this.get_authorname_box_size({
              text: `${authorName}`,
              fontSize: standardFont.authorNameFontSize
          });
          // 4. 创建作者名
          const _authorName = new fabric.Textbox(`${authorName} 著`, {
              fontSize: standardFont.authorNameFontSize,
              width: _innerAuthorName.width,
              fill,
              top: bookNameTop + _bookName.height + standardFont.margin,
              type: 'authorName',
              textAlign: 'center',
              splitByGrapheme: true,
              id: uuid(),
              fontFamily: '',
              fontId: '',
              editable: false
          });

          this.canvas.add(_imgInstance);
          this.canvas.add(_bookName);
          this.canvas.add(_authorName);
          this.canvas.setActiveObject(_bookName); // 激活书名选中状态
          this.canvas.viewportCenterObjectH(_bookName);
          this.canvas.viewportCenterObjectH(_authorName);

          // 画布是否有数据的监听器
          this.event.emit('on_empty', true);
          this.event.emit('on_edit_loading', false, 'fontFamilyText');
          resolve(true);
      }, () => {
          Message({
              message: '封面数据异常,请换个模板',
              type: 'error'
          });
          this.event.emit('on_empty', false);
          this.event.emit('on_edit_loading', false, 'fontFamilyText');
          resolve(false);
      });
  });
}

下载字体

字体属性可以自定义字体,需要先下载字体后再进行设置,可以通过fontfaceobserver工具库下载指定字体,成功后在设置字体名称

/**
 * 动态下载字体文件
 * @param {Array | Object} fontFamilys {fontCode:'',url:'',}
 * @return {Promise}
 */
export function js_tools_download_font(fontFamilys) {
    const _skipFonts = ['arial', 'Microsoft YaHei', 'Times New Roman', 'custom-cover-font-undefined'];
    let _fontFamilysAll = [];
    let _innerFontFamilys = [];
    if (Array.isArray(fontFamilys)) {
        _innerFontFamilys = fontFamilys;
    } else {
        _innerFontFamilys = [fontFamilys];
    }
    _fontFamilysAll = _innerFontFamilys.map((fontItem) => {
        const _font = !_skipFonts.includes(fontItem.fontCode) && new FontFaceObserver(fontItem.fontCode);
        return _font.load(null, 150 * 1000);
    });

    return Promise.all(_fontFamilysAll);
}

/**
* 动态生成font-face
* @param {Array | Object} fontObj: {fontCode:'',url:'',}
 */
export function js_tools_init_font_family(fontObj) {
    let _fontArr = [];
    let _styleEle;
    let _style;
    let _text;
    if (Array.isArray(fontObj)) {
        _fontArr = fontObj;
    } else {
        _fontArr = [fontObj];
    }
    _fontArr.forEach(font => {
        _styleEle = document.getElementById(font.fontCode);
        if (!_styleEle) {
            _style = document.createElement('style');
            _style.id = font.fontCode;
            _style.type = 'text/css';
            _text = ` @font-face {font-family:'${font.fontCode}';src:url('${font.url}')}`;
            _style.innerText = _text;
            document.getElementsByTagName('head')[0].appendChild(_style);
        }
    });
}

保存

保存是需要生成600*800的标准尺寸,当画布大小发生变化后,仍然要导出600*800的图片

// 保存图片
save_pic() {
    const {coverWidth, coverHeight} = this.config;
    // 减去0.001是为了防止导出的图片有白边
    const _zoomCounter = this.zoomCounter - 0.001;
    // 转换成base64
    const _imgURL = this.canvas.toDataURL({
        format: 'jpeg',
        quality: 1,
        multiplier: 1 / _zoomCounter,
        left: 0,
        top: 0,
        width: coverWidth * _zoomCounter,
        height: coverHeight * _zoomCounter
    });
    return _imgURL;
}

图片懒加载

自定义封面模板有很多图片,必须使用懒加载和滚动加载的方式,否则一次性加载几百张图片会大大消耗性能

// 在封面组件中配置isLazyLoad为true
<img-cover
    :imgSrc="tpl.cover_url"
    :isLazyLoad="true"
    :imgAlt="tpl.tpl_name"
    hoverStyle="big"
>
    <check-img :checkStatus="tpl.id == currentTpl.tplId"></check-img>
</img-cover>

// img-cover.vue
<template>
    <div
        class="img-cover"
        ref="ref-img-cover"
        :class="[hoverStyle?`animate-${hoverStyle}`:'']"
        >
        <a
            href="javascript:void(0)">
            <img
                :src="innerImgSrc"
                class="img-cover-src"
                :alt="imgAlt"
                v-lazyload="{imgSrc, isLazyLoad}"
                @error="img_on_error"
            >
            <slot></slot>
        </a>
    </div>
</template>

// lazyload.js
import {
    js_tools_throttle
} from '../utils/tools';

const add_listener_observe = function(el) {
    const _realSrc = el?.dataset?.src;
    const _io = new IntersectionObserver(
        entries => {
            if (entries[0].isIntersecting) {
                if (_realSrc) {
                    el.src = _realSrc;
                    el.removeAttribute('data-src');
                }
            }
        },
        {
            root: document.querySelector('.material-content')
        }
    );
    _io.observe(el);
};
const add_listener_scroll = function(el) {
    const handler = js_tools_throttle(load_image, 300, true);
    load_image(el);
    window.addEventListener('scroll', () => {
        handler(el);
    });
};
const load_image = function(el) {
    const _windowHeight = document.documentElement.clientHeight || document.body.clientHeight;
    const _elTop = el.getBoundingClientRect().top;
    const _elBtm = el.getBoundingClientRect().bottom;
    const _realSrc = el.getAttribute('data-src');
    if (_elTop - _windowHeight < 0 && _elBtm > 0) {
        if (_realSrc) {
            el.src = _realSrc;
            el.removeAttribute('data-src');
        }
    }
};
const init_image = function(el, value) {
    el.setAttribute('data-src', value);
};

export default {
    bind(el, binding) {
        const {imgSrc, isLazyLoad = true} = binding.value;
        isLazyLoad && init_image(el, imgSrc);
    },
    inserted(el, binding) {
        const {isLazyLoad = true} = binding.value;
        if (!isLazyLoad) return;
        if (window.IntersectionObserver) {
            add_listener_observe(el);
        } else {
            add_listener_scroll(el);
        }
    }
};


属性调整

// 文字-属性改变
  handle_change_common(key, value) {
    let _nValue = value;
    switch (key){
        case 'textAlign':
            this.fontAttr.textAlign = _nValue;
            break;
        case 'charSpacing':

            if (value > this.fontConfigData.maxSpacing){
            // 如果输入值大于 限制的 就取限制
                  _nValue = this.fontConfigData.maxSpacing;
              }
              break;
          case 'fill':
              this.fontAttr.fill = _nValue;
              break;
          case 'backgroundColor':
              this.fontAttr.textBackgroundColor = _nValue;
              break;
          default:
              this.fontAttr[key] = _nValue;
              break;
      }
      this.fontAttr[key] = _nValue;
      this.set_active_object(key, _nValue); // 设置并且更新当前激活 canvas 对象
  }

主副标题

主副标题中,文字的拖动位置在组件中需要双向改变,初始化的时候需要生成默认位置,默认位置在第一个标点符号之后,如果没有标点符号则取配置的默认位置,当拖动组件的位置,需要将拖动的位置,和标点符号的位置做对比,取最近的一个标点符号位置之后。拖动完成后需要按照主副标题规则重新设置封面的上的文字大小。

// 主副标题规则
mainSubTitleRule: {
      mainMaxLength: 10, // 主标题一行最多能放的字数
      mainFontSize: {
          10: 54, // 主标题10个字的时候,,字号为54
          9: 60, // 主标题9个字的时候,字号为60
          8: 68,
          7: 78,
          6: 88,
          5: 106,
          4: 126,
          3: 126,
          2: 126,
          1: 126
      },
      subMaxLength: 13, // 副标题一行最多能放的字数
      subFontSize: {
          1: 60, // 副标题1个字的时候,字号为60
          2: 60, // 标题2个字的时候,字号为60
          3: 60,
          4: 60,
          5: 60,
          6: 46,
          7: 46,
          8: 46,
          9: 46,
          10: 46,
          11: 40,
          12: 40,
          13: 40
      }
  }

总结

自定义封面功能,从最初的产品需求,到方案调研,到开发完成,再到功能组件化,经历了漫长的过程。开发中所依赖的fabricjs库也是我们第一次使用,它的功能还是非常强大的,帮助我们高效的开发出了一个简单的封面生成工具,不过也遇到了一些奇怪的兼容性问题,例如百分比渐变色在某些浏览器不生效等问题,目前也还在优化迭代中,如果大家有遇到类似的功能,欢迎来一起探讨。