经过一段时间的学习,我终于学会了canvas的一些基本使用,于是我新启了一个vue的项目,开始尝试着去开发一些这个相册的基本功能。
经过一个多星期的努力,可算是熬出来点东西。
首先,我想到的是开始组装数据,因为既然是要在我们系统中去制作这个相册,那肯定相册得有数据去驱动,一本相册可能很多页,所以相册的数据肯定是数组对象的格式,每一页相册得有一下几个数据:页码、页面背景、页面宽度、页面高度、和页面内可以操作的元素的信息,按照boss的要求,得有可以操作的区域,比如图片和文本,也就是页面有几个位置可以去编辑这个图片和文本内容,所以在页面的信息中得有一个保存这些内容的数据集合——content,然后这个数据也得是一个数组对象格式,由于可以操作的元素分为两种(图片和文本),所以得分别去定义两种元素的参数信息,因为这些元素都得canvas中去绘制,所以它们都离不开这几个信息:x轴、y轴、宽度、高度,此外还得有个字段去区分这两个元素——type,然后就是一些有差异的参数,比如图片,绘制图片肯定得需要图片的路径,绘制文本得有文本的字体大小、字体、颜色、最大宽度(如果不做限制,不管多长的文本会在一行显示)、行高等
最终决定组成的数据大概长这样,当然这只是其中两页(全部贴上去太长了。。)
[{
content: [
{
url: '',
x: 309,
y: 1432,
width: 959,
height: 959,
type: 'image'
}, {
url: '',
x: 1875,
y: 1368,
width: 697,
height: 697,
type: 'image'
}, {
url: '',
x: 65,
y: 2552,
width: 960,
height: 929,
type: 'image'
}, {
url: '',
x: 1142,
y: 2111,
width: 1301,
height: 1301,
type: 'image'
}
],
date: '',
pageNo: 6,
templateBgUrl: 'https://hzkwtz.obs.cn-east-2.myhuaweicloud.com/albumTemplate_wnjy/template1/6.png',
width: 2929,
height: 3909,
sign: 'pages'
},{
content: [
{
url: '',
x: 117,
y: 796,
width: 2575,
height: 1591,
type: 'image'
},
{
word: '',
fontSize: 145,
fontFamily: '黑体',
color: '#3f3b3a',
x: 245,
y: 2945,
maxWidth: 2450,
l_h: 145,
maxLine: 1,
width: 2450,
height: 145,
textAlign: 'left',
type: 'text'
}
],
date: '',
pageNo: '封面',
templateBgUrl: 'https://hzkwtz.obs.cn-east-2.myhuaweicloud.com/albumTemplate_wnjy/template1/%E5%B0%81%E9%9D%A21.png',
width: 2929,
height: 3909,
sign: 'pages'
}]
已经组装好数据,接下来就得搞个生成这些数据的页面,因为我们这个相册肯定不会只做一个模板,也不可能让用户去自己组装这些数据,所以还得有个页面专门去做这个模板,当然这个页面我已经初步完成了,他目前大概长这样。
左边的部分也是用canvas生成的,由于没有产品也没有美工,看上去也是比较丑,但是能用就行。当然由于本人能力有限,做出来的用法也是比较蠢,就是得让美工出一套相册的模板psd文件,然后将需要用户上传图片的区域镂空,出一套相册的背景图,页面上传好背景图,然后打开模板的psd文件一个元素一个元素的量取对应的参数。顺便我也找到我们的后端同学给我写了个接口,供我保存这些数据。这里我花了大概一上午时间去做了一套模板出来,大概二十多页。这个时候boss又来提需求了,由于我们系统是给学校用的,相册也都老师去制作,所有,老师给每个学生制作相册的时候有些地方得自动填写学生的姓名、班级等信息,所以这边我又做了一个文本变量预设这么一个玩意儿
接下来就是老师制作相册的页面了
相册编辑页面大概长这样,因为是自己练手的,所以比较丑,分为三个区域,最左边的是相册页面主体,也就是主要的操作区域,中间有个导出按钮,最右边是素材区,可以直接从右侧拖拽文本或者照片到左侧进行调节。
我将这个页面的实现主要划分为三个部分:
1. canvas相册页的绘制
2. 右侧选择图片拖拽到左侧
3. 元素位置的选中
4. 操作元素(拖拽和放大)
首先是相册页面的绘制,考虑到要导出图片,所以我这次选择的肯定是canvas去完成。由于相册模板的各个参数信息都是直接使用ps量取的,在ps中相册的宽度高达三四千的像素,而我们在页面中展示肯定不能展示那么大,于是我使用了scale对整个画布进行缩放,但是缩放大小得计算,
getTemplate(this.templateId).then(async res => { // 调用接口,获取相册模板的信息
let canvasSettingWidth = 500 // 设置canvas标签的宽度为500像素
this.canvasW = res.data.width // 相册页面原始宽度
this.canvasH = res.data.height // 相册页面原始高度
this.canvasProportion = canvasSettingWidth / this.canvasW // 缩放比例 = canvas标签的宽度 / 相册页原始宽度
})
得到缩放比例后,接下来就是设置canvas标签的宽和高,canvas标签给我们提供了width和height两个属性去控制元素的宽和高,如果通过css去修改canvas宽高,会发现绘制完后显示有问题。
如何去做这种图片在某个特定的区域显示的效果呢,这边我参考了一下photoshop里的图层和蒙版概念,只要让美工给我将背景图的可放置图片区域扣掉,变成透明的,这样我先绘制图片,在绘制背景,就可以达到这种效果。
接下来就是封装一个公共的绘制方法,在这个方法里,主要接收几个入参——Ctx(canvas实例), option(每一页的数据), proportion(缩放比例)
首先得开启一个画布的缩放,缩放比例为上方获取的 canvasProportion,上方虽然给canvas标签进行了缩放,但是如果绘制过程没有开启缩放,会导致绘制的内容过大,画布只显示左上角一小角,后面导出图片的时候也可以通过这个参数设置导出图片的清晰度,为1的时侯导出的照片就是完全的"高保真"了。
Ctx.scale(proportion, proportion)
然后就是开始绘制的逻辑,绘制的内容主要有三个: 图片、文字、背景。由于canvas一般都是后绘制的覆盖先绘制的,所以我这边绘制的顺序是 图片 – 背景 – 文字。我们通过遍历option的content属性(保存页面的元素), 按顺序去绘制。下面是绘制画布的代码:
const createAlbumPages = async(Ctx, option, proportion = 0.12) => {
return new Promise((resolve, reject) => {
(async() => {
try {
Ctx.save()
/* 开启缩放(为了保证在手机/电脑正常显示,并且打印的照片足够高清,所以需要对canvas进行整体的缩放) */
Ctx.scale(proportion, proportion)
let selectBox // 选中框描边数据变量
// 处理页面中图片
let imgIndex = 1
await CustomForeach(option.content, async com => {
if (com.type == 'image') {
let newImgConf
if (com.url == 'empty') { // 未选择照片时的图片置空区域
if (com.selected) { // selected 用于当前点击区域选中的一个标记
selectBox = [com.x, com.y, com.width, com.height] // 没照片时候点击需要聚焦区域的范围,所以得保存坐标和宽高等信息
}
// 用于鼠标点击范围的判断
com.square = [[com.x * proportion, com.y * proportion], [com.x * proportion + com.width * proportion, com.y * proportion], [com.x * proportion + com.width * proportion, com.y * proportion + com.height * proportion], [com.x * proportion, com.y * proportion + com.height * proportion]]
} else { // 未选择照片时的图片置空区域
// 因为每次拖动或者放大图片都会加载一边,会非常耗时,所以此处做了个缓存处理
let imageInfo
if (com.imageInfo) {
imageInfo = com.imageInfo
} else {
imageInfo = await getImageInfo({ imgSrc: com.url }).catch(err => { reject(err) }) // getImageInfo是下载并获取网络图片路径的方法,下文会给出说明
com.imageInfo = imageInfo
}
// 根据相册模板初始化图片(缩放图片宽高)
let width = imageInfo.width
let height = imageInfo.height
if (width != com.width) {
height = Math.floor((com.width / width) * height)
width = com.width
}
newImgConf = {
// 后期移动图片直接修改offsetX 和 offsetY 属性,缩放则修改 scalc
x: com.x + com.offsetX,
y: com.y + com.offsetY,
width: width * com.scalc,
height: height * com.scalc,
path: imageInfo
}
/* 绘制图片(为了防止图片太近出现图片重叠bug,需要先绘制路径进行裁切,然后才能绘制图片) */
Ctx.save()
// 绘制裁切路径
Ctx.beginPath()
Ctx.strokeStyle = '#fff'
Ctx.moveTo(com.x, com.y)
Ctx.lineTo(com.x + com.width, com.y)
Ctx.lineTo(com.x + com.width, com.y + com.height)
Ctx.lineTo(com.x, com.y + com.height)
Ctx.lineTo(com.x, com.y)
Ctx.stroke()
// 裁切
Ctx.clip()
// 绘制图片
Ctx.drawImage(newImgConf.path, newImgConf.x, newImgConf.y, newImgConf.width, newImgConf.height)
Ctx.restore()
// 选中的话设置选中框参数
if (com.selected) { // selected 用于当前点击区域选中的一个标记
selectBox = [newImgConf.x, newImgConf.y, newImgConf.width, newImgConf.height]
}
// 用于鼠标点击范围的判断
com.square = [[(com.x + com.offsetX) * proportion, (com.y + com.offsetY) * proportion], [(com.x + com.offsetX) * proportion + com.width * proportion, (com.y + com.offsetY) * proportion], [(com.x + com.offsetX) * proportion + com.width * proportion, (com.y + com.offsetY) * proportion + newImgConf.height * proportion], [(com.x + com.offsetX) * proportion, (com.y + com.offsetY) * proportion + newImgConf.height * proportion]]
}
imgIndex++
// 用于判断用户点击区域是否为当前图片
// com.square = [[com.x * proportion, com.y * proportion], [com.x * proportion + com.width * proportion, com.y * proportion], [com.x * proportion + com.width * proportion, com.y * proportion + com.height * proportion], [com.x * proportion, com.y * proportion + com.height * proportion]]
}
})
// 插入模板背景
if (option.templateBgUrl) {
let bgImageInfo
// 缓存背景图片
if (option.bgImageInfo) {
bgImageInfo = option.bgImageInfo
} else {
bgImageInfo = await getImageInfo({ imgSrc: option.templateBgUrl }).catch(err => { reject(err) })
option.bgImageInfo = bgImageInfo
}
Ctx.drawImage(bgImageInfo, 0, 0, option.width, option.height)
}
let findSelected // 选中的元素变量
await CustomForeach(option.content, com => {
if (com.type == 'text') { // 处理文本内容
if (com.selected) {
selectBox = [com.x + com.offsetX, com.y + com.offsetY, com.width, com.height]
}
com.square = [[(com.x + com.offsetX) * proportion, (com.y + com.offsetY) * proportion], [(com.x + com.offsetX) * proportion + com.width * proportion, (com.y + com.offsetY) * proportion], [(com.x + com.offsetX) * proportion + com.width * proportion, (com.y + com.offsetY) * proportion + com.height * proportion], [(com.x + com.offsetX) * proportion, (com.y + com.offsetY) * proportion + com.height * proportion]]
const { fontSize = 14, fontFamily = '黑体', color = '#000000', x = 0, y = 0, maxWidth = 200, l_h = 20, maxLine = 4, textAlign = 'left', offsetX, offsetY } = com
let { word } = com
if (showContent) {
// dealWords是封装的绘制文本的方法,由于canvas不会自动对文本进行换行,所以得单独处理,下文会具体说明
dealWords({ font: `${fontSize}px ${fontFamily}`, Ctx, color, word: word, maxWidth: maxWidth, textAlign, maxLine, x: x + offsetX, y: y + offsetY, l_h: l_h })
}
}
com.selected && (findSelected = com.selected) // 判断是否选中
})
if (findSelected) {
// 如果有选中的元素,给选中的部分绘制线框
Ctx.setLineDash([20, 20])
Ctx.lineWidth = 15
Ctx.strokeStyle = 'red'
Ctx.strokeRect(...selectBox)
}
Ctx.restore()
resolve(option) // 结束
} catch (err) {
console.log(err)
reject(err)
}
})()
})
}
在上述代码中涉及到三个方法,分别是 —— getImageInfo 、 dealWords 和 CustomForeach,代码及注释贴上:
/**
* @description 处理canvas文本换行函数,解决canvas绘制文本不会自动换行问题
* @param {Object} options 配置项
* @property {array} font: "22px" + family, 字体大小
* @property {array} Ctx: Ctx, uni.createCanvasContext('firstCanvas')
* @property {array} word: "文字", 文字
* @property {array} textAlign: "left", 排列
* @property {array} color: "#000", 颜色
* @property {array} maxWidth: 750, 最大宽度
* @property {array} maxLine: 2, 最大行数
* @property {array} x: 100, x坐标
* @property {array} y: 100, y坐标
* @property {array} l_h: 40 行高
*/
function dealWords(options) {
let word = options.word
if (options.word) {
word = deepProcessingText(options.word) // deepProcessingText是处理预设文本的一个递归函数
}
options.Ctx.beginPath() // 新建一条path
/* 初始化文本属性 */
options.Ctx.fillStyle = options.color // 颜色
options.Ctx.textAlign = options.textAlign // 对齐方式
options.Ctx.font = options.font // 字体信息 和 参考css 的fant属性
// measureText() 方法返回包含一个对象,该对象包含以像素计的指定字体宽度。
var allRow = Math.ceil(options.Ctx.measureText(word).width / options.maxWidth) // 计算出实际总共能分多少行
var count = allRow
var endPos = 0 // 当前字符串的截断点
for (var j = 0; j < count; j++) {
var nowStr = word.slice(endPos) // 当前剩余的字符串
var rowWid = 0 // 每一行当前宽度
if (options.Ctx.measureText(nowStr).width > options.maxWidth) { // 如果当前的字符串宽度大于最大宽度,然后开始截取
for (var m = 0; m < nowStr.length; m++) {
rowWid += options.Ctx.measureText(nowStr[m]).width // 当前字符串总宽度
if (rowWid > options.maxWidth) {
if (j === options.maxLine - 1) { // 如果是最后一行
options.Ctx.fillText(nowStr.slice(0, m - 1) + '...', options.x, options.y + (j + 1) * options.l_h) // (j+1)*18这是每一行的高度
} else {
options.Ctx.fillText(nowStr.slice(0, m), options.x, options.y + (j + 1) * options.l_h)
}
endPos += m // 下次截断点
break
}
}
} else { // 如果当前的字符串宽度小于最大宽度就直接输出
options.Ctx.fillText(nowStr.slice(0), options.x, options.y + (j + 1) * options.l_h)
}
}
options.Ctx.closePath()
}
/**
* @description 下载网络图片并返回图片参数信息
*/
const getImageInfo = async({ imgSrc }) => {
return new Promise((resolve, errs) => {
var img = new Image() // 创建一个元素
img.onload = function(e) {
resolve(img) // 返回图片信息
}
img.onerror = function(e) {
errs(e)
}
img.src = imgSrc // 设置图片源地址
})
}
/**
* @description 自定义forEach函数,用来解决普通forEach函数无法使用async,await问题,本案例中很多地方均有使用,用于解决异步问题导致的操作时许作乱报错
* @param {Array} arr 遍历的数组
* @param {Function} callback 处理回调
*/
export const CustomForeach = async(arr, callback) => {
const length = arr.length
const O = Object(arr)
let k = 0
while (k < length) {
if (k in O) {
const kValue = O[k]
await callback(kValue, k, O)
}
k++
}
}
这个时候只要把对应的相册数据传进来就可以渲染出来了,但是只是渲染出来还是不够,还需要可以点击选中,可以拖动照片/文字,可以放大缩小照片/文字。图片的移动和缩放主要由offsetX、offsetY 和 scalc这三个属性去完成,具体可参考上方原理见上方createAlbumPages方法。所以需要给canvas标签加上一个鼠标按下的事件——mousedown,考虑后期还得加自定义右键菜单,所以得加一个时间修饰符 “.left” 使得鼠标左键按下才能触发事件。
然后就是处理逻辑:
async drawCanvas() { // 绘制canvas
const canvas = document.querySelector('#canvas')
if (canvas.getContext) {
const Ctx = canvas.getContext('2d')
Ctx.clearRect(0, 0, this.canvasW, this.canvasH)
// 调用绘制画布方法
await createAlbumPages(Ctx, this.albumDetails[this.currentPage], this.canvasProportion, true).catch(err => {
console.log('---生成失败', err)
})
} else {
console.log('不支持getContext')
}
},
async touchStart(e) { // canvas触摸开始
e.preventDefault() // 取消默认事件
const { offsetX, offsetY } = e // 获取相对于触发事件对象的坐标
let selectArea = null
await CustomForeach(this.albumDetails[this.currentPage].content, async(com) => { // 聚焦选中元素
const oldSelect = com.selected // 保存当前选择的元素指针
com.selected = this.insidePolygon(com.square, [offsetX, offsetY]) // insidePolygon函数根据点击坐标和绘制画布时计算的点击范围判断是否在点击区域内,返回布尔值
if (com.selected) {
selectArea = com
}
})
this.drawCanvas()
if (selectArea) {
// 保存旧的点击位置坐标
const oldOffsetX = selectArea.offsetX
const oldOffsetY = selectArea.offsetY
document.onmousemove = this.throttle(async(e) => { // 处理拖动逻辑
const moveX = (e.offsetX - offsetX) / this.canvasProportion // 计算 x 轴移动距离
const moveY = (e.offsetY - offsetY) / this.canvasProportion // 计算 y 轴移动距离
const newOffsetX = oldOffsetX + moveX // 计算图片基于原本位置偏移的 x 轴距离
const newOffsetY = oldOffsetY + moveY // 计算图片基于原本位置偏移的 y 轴距离
// 赋值
selectArea.offsetX = newOffsetX
selectArea.offsetY = newOffsetY
// 更新画布
this.drawCanvas()
}, 10)
document.onmouseup = (e) => { // 鼠标松开,释放监听函数
document.onmousemove = null
}
} else {
this.drawCanvas()
}
},
insidePolygon(points, testPoint) {
const x = testPoint[0]
const y = testPoint[1]
let inside = false
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i][0]
const yi = points[i][1]
const xj = points[j][0]
const yj = points[j][1]
const intersect =
yi > y != yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
if (intersect) inside = !inside
}
return inside
}
移动图片有了,接下来就是鼠标滚轮缩放图片,加上 mousewheel 事件,以及 prevent 修饰去除默认事件
处理逻辑:
zoom(e) { // 处理鼠标滚动放大缩小元素
this.throttle(() => {
if (this.judgeSelectAreaEmpty()) { // judgeSelectAreaEmpty函数判断当前是否有选中区域,因为直邮选中才能缩放,这边也为了防止未选中报错
const direction = e.deltaY > 0 ? 'down' : 'up' // 区分放大还是缩小
const range = 0.04 // 每次缩放比例
if (direction === 'up') {
// 放大
if (this.selectArea.type == 'image') {
this.selectArea.scalc += range // 图片的话直接修改 scalc 参数,原理见createAlbumPages方法
} else if (this.selectArea.type == 'text') {
this.selectArea.fontSize += 4
}
} else {
// 缩小
if (this.selectArea.type == 'image') {
this.selectArea.scalc -= range
this.selectArea.scalc = this.selectArea.scalc <= 0.1 ? 0.1 : this.selectArea.scalc // 控制最小缩放比例为 0.1
} else if (this.selectArea.type == 'text') {
this.selectArea.fontSize -= 4
this.selectArea.fontSize = this.selectArea.fontSize <= 30 ? 30 : this.selectArea.fontSize // 控制字体最小为 30 像素
}
}
this.drawCanvas()
})()
},
至此,元素的拖拽和缩放都完成了,不过还有很多细节需要调整,这边只展示大概的逻辑。拖拽添加照片和文本的逻辑有点多,也比较简单,使用了HTML5新增的 draggable 属性去完成的,这里就不一一说明了。然后要说的是导出成照片,这里不仅需要将每张图片导出,而且还需要每两张合并一张去导出,我这里想到的就是先将每张canvas单独导出,将导出的数据批量存入一个数组,然后再用一个新的canvas去做拼接处理,最后打包成压缩包,下载导出,这边使用了 jszip 和 file-saver 两个插件,代码和注释如下:
html新增部分:
js部分:
async downloadPic() { // 打包相册图片为png并压缩,导出
// 渲染每页轮播图
this.downloadCanvas = true
this.$nextTick(async() => {
const canvas = document.querySelector('#downloadCanvas')
if (canvas.getContext) {
const Ctx = canvas.getContext('2d')
this.blurElement(false) // 取消元素选中,否则会把选中的框框一起绘制出来
this.albumPackages = []
let arr = []
await CustomForeach(this.albumDetails, async(com, index) => { // 自定义forEach
Ctx.clearRect(0, 0, this.canvasW, this.canvasH) // 清空画布
await createAlbumPages(Ctx, com, 1).then(async() => { // 绘制画布,并设置清晰度为1
const src = canvas.toDataURL('image/png') // 绘制结束,将canvas对象转换为base64位编码
arr.push({ name: com.pageNo + '.png', src, pageNo: com.pageNo }) // 保存
// 将生成的图片信息(base64格式)每两个一存,方便后面合并
if (arr.length == 2) {
this.albumPackages.push(arr)
arr = []
}
}).catch(err => {
console.log('---生成失败', err)
})
})
// 将图片对半拼接
this.splicingCanvas = true
this.$nextTick(async() => {
try {
const splicingCanvas = document.querySelector('#splicingCanvas')
const splicingCtx = splicingCanvas.getContext('2d')
const zip = new JSZip() // 创建 zip 构造函数
await CustomForeach(this.albumPackages, async(s, i) => { // 遍历保存的相册合集
splicingCtx.clearRect(0, 0, this.canvasW * 2, this.canvasH) // 清除画布
splicingCtx.save()
splicingCtx.scale(1,1)
分别下载两张需要拼接图片
const src1 = await getImageInfo({ imgSrc: s[0].src })
const src2 = await getImageInfo({ imgSrc: s[1].src })
绘制到画布上
splicingCtx.drawImage(src1, 0, 0, this.canvasW, this.canvasH)
splicingCtx.drawImage(src2, this.canvasW, 0, this.canvasW, this.canvasH) // 第二张图也就是右边部分,x轴就是第一个图的宽度
splicingCtx.restore()
const src = splicingCanvas.toDataURL('image/png') // 将canvas对象转换为base64位编码
await zip.file(i + '.png', src.substring(22), { base64: true }) // 打包
})
zip.generateAsync({ type: 'blob' }).then(async content => { // 结束压缩并生成文件
await FileSaver.saveAs(content, '相册.zip')
this.$message({
message: '导出成功',
type: 'success'
})
setTimeout(() => {
this.downloadCanvas = false
}, 3000)
})
console.groupEnd()
} catch (err) {
console.error(`图片拼接错误:`, err)
}
})
} else {
console.log('不支持getContext')
}
})
},
至此,导出图片的部分也结束了,本篇介绍的均为自己练习时候的案例,后面移植的时候做了很多优化,同样的逻辑还有小程序的部分,就不介绍了,大体思路都是一样的,就是有些api有点差异,而且运行起来没有pc那么流畅,可能是优化的问题,确实用了太多循环和异步的执行。
第一次用canvas,特地分享并记录一下,本篇不适合前端大神阅读,当然,如果已经看完并发现漏洞或者写的不对不严谨的地方也欢迎指出。