需求背景
自定义封面经过了多版本的迭代:
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-editor ② react-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库也是我们第一次使用,它的功能还是非常强大的,帮助我们高效的开发出了一个简单的封面生成工具,不过也遇到了一些奇怪的兼容性问题,例如百分比渐变色在某些浏览器不生效等问题,目前也还在优化迭代中,如果大家有遇到类似的功能,欢迎来一起探讨。