封装原因
在项目中有时候会需要前端画出一个宣传海报来,这时候就会使用到canvas来制作了
目前仅封装了绘制以下图形
- 图片
- 矩形
- 文字
- 圆
- 头像
参数
- width:canvas的宽度(因为是基于uniapp封装的,所以单位是rpx)
- height:canvas的高度(因为是基于uniapp封装的,所以单位是rpx)
- drawArr:绘制素材的配置数组
配置项
drawArr数组对象参数
- type 元素类型(字符串)可选值:image 图片、rect 矩形、arc 圆形、avatar 头像、text 文字
- drawOptions 元素绘制参数(对象)
注意:因为是基于uniapp封装的,所以配置项的属性的单位是rpx(1px = 2rpx)
drawOptions的参数(image 图片)
| 属性 | 说明 | 可选值 |
|---|---|---|
| left | 元素距离canvas左侧的距离 | 数字或center,center表示水平居中,例如:10,'center' |
| right | 元素距离canvas右侧的距离 | 数字,例如:10 |
| top | 元素距离canvas顶部的距离 | 数字或center,center表示垂直居中,例如:10,'center' |
| bottom | 元素距离canvas底部的距离 | 数字,例如:10 |
| width | 元素宽度 | 数字或字符串,例如:10、'100%' |
| height | 元素高度 | 数字或字符串,例如:10、'100%' |
| url | 图片的网络地址 | 字符串 |
drawOptions的参数(rect 矩形)
| 属性 | 说明 | 可选值 | |
|---|---|---|---|
| left | 元素距离canvas左侧的距离 | 数字或center,center表示水平居中,例如:10,'center' | |
| right | 元素距离canvas右侧的距离 | 数字,例如:10 | |
| top | 元素距离canvas顶部的距离 | 数字或center,center表示垂直居中,例如:10,'center' | |
| bottom | 元素距离canvas底部的距离 | 数字,例如:10 | |
| width | 元素宽度 | 数字,例如:10 | |
| height | 元素高度 | 数字,例如:10 | |
| type | 绘制的类型 | 字符串,例如:'fill'(fill填充、stroke描边) | |
| fillStyle | 填充颜色 | 字符串,例如:'#333333' | |
| strokeStyle | 描边颜色 | 字符串,例如:'#333333' | |
| isFillet | 是否有圆角 | 布尔值,例如:true | |
| radius | 圆角值 | 数字,例如:10 |
drawOptions的参数(arc 圆形)
| 属性 | 说明 | 可选值 | |
|---|---|---|---|
| left | 元素距离canvas左侧的距离 | 数字或center,center表示水平居中,例如:10,'center' | |
| right | 元素距离canvas右侧的距离 | 数字,例如:10 | |
| top | 元素距离canvas顶部的距离 | 数字或center,center表示垂直居中,例如:10,'center' | |
| bottom | 元素距离canvas底部的距离 | 数字,例如:10 | |
| type | 绘制的类型 | 字符串,例如:'fill'(fill填充、stroke描边) | |
| fillStyle | 填充颜色 | 字符串,例如:'#333333' | |
| strokeStyle | 描边颜色 | 字符串,例如:'#333333' | |
| radius | 半径 | 数字,例如:10 |
drawOptions的参数(avatar 头像)
| 属性 | 说明 | 可选值 |
|---|---|---|
| left | 元素距离canvas左侧的距离 | 数字或center,center表示水平居中,例如:10,'center' |
| right | 元素距离canvas右侧的距离 | 数字,例如:10 |
| top | 元素距离canvas顶部的距离 | 数字或center,center表示垂直居中,例如:10,'center' |
| bottom | 元素距离canvas底部的距离 | 数字,例如:10 |
| width | 头像的宽度 | 数字,例如:10 |
| height | 头像的高度 | 数字,例如:10 |
| url | 头像的网络地址 | 字符串 |
drawOptions的参数(text 文字)
| 属性 | 说明 | 可选值 | |
|---|---|---|---|
| left | 元素距离canvas左侧的距离 | 数字或center,center表示水平居中,例如:10,'center' | |
| right | 元素距离canvas右侧的距离 | 数字,例如:10 | |
| top | 元素距离canvas顶部的距离 | 数字或center,center表示垂直居中,例如:10,'center' | |
| bottom | 元素距离canvas底部的距离 | 数字,例如:10 | |
| type | 绘制的类型 | 字符串,例如:'fill'(fill填充、stroke描边) | |
| fillStyle | 填充颜色 | 字符串,例如:'#333333' | |
| strokeStyle | 描边颜色 | 字符串,例如:'#333333' | |
| text | 文本内容(仅绘制text时有效) | 字符串,例如:'你好,世界' | |
| maxLine | 文本最大行数(仅绘制text时有效) | 数字,例如:2 | |
| maxWidth | 文本最大宽度(仅绘制text时有效) | 数字,例如:100 | |
| fontSize | 文本字体大小(仅绘制text时有效) | 数字,例如:30 | |
| italic | 文本是否斜体(仅绘制text时有效) | 布尔类型,例如:true | |
| bold | 文本是否加粗(仅绘制text时有效) | 布尔类型,例如:true | |
| fontFamily | 文本字体(仅绘制text时有效) | 字符串,例如:'PingFang SC' | |
| lineHeight | 文本行高(仅绘制text时有效) | 数字,例如:1.2 | |
| underlineOption | 文本是否设置下划线以及下划线的配置(仅绘制text时有效) | 对象 | |
| deleteLineOption | 文本是否设置删除线以及删除线的配置(仅绘制text时有效) | 对象 | |
| specialTreatment | 文本是否进行特殊处理(仅绘制text时有效) | 对象数组 |
drawOptions的参数(text 文字)参数解释
underlineOption和deleteLineOption:
数据结构如下:
- color:线条的颜色
- offset:偏移量,下划线的偏移量是下划线距离文字底部的距离
- 删除线不需要偏移量,因为删除线一般都是在文字中间的
- size:线条的大小
specialTreatment:
数据结构如下:
目前只写了两种特殊处理
- 改变特定文字的颜色
- 指定换行标识:文本渲染时遇到指定的换行标识就立即换行
示例结构的效果如下图
注意:
- 在上图的示例中文本内容是:默认文本/好的哈/大家好/打开就 ,遇到/字符就自动换行了
- 如果声明指定了换行字符,请手动计算maxLine的值,计算公式:换行字符数量+1
如果需要其他的处理,可以根据需要自行修改源码
代码
<template>
<div
class="canvas-box"
:style="{
width: props.width + 'rpx',
height: props.height + 'rpx',
}"
@click.stop=""
>
<template v-if="canvansWidth && !canvasImageUrl">
<canvas
:style="{
width: canvansWidth + 'px',
height: canvansHeight + 'px',
}"
canvas-id="myCanvas"
class="myCanvas"
></canvas>
</template>
<image v-if="canvasImageUrl" :src="canvasImageUrl" mode="widthFix" show-menu-by-longpress />
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch, getCurrentInstance, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
const { proxy } = getCurrentInstance();
let example = proxy;
const props = defineProps({
drawArr: {
type: Array,
default: [],
required: true,
},
width: {
type: Number,
required: true,
},
height: {
type: Number,
required: true,
},
});
const emit = defineEmits(['canvasDrawComplete']);
onMounted(async () => {
uni.showLoading({ title: '绘制中...' });
init();
await startDrawing();
canvasImageUrl.value = await getCanvasImageUrl();
emit('canvasDrawComplete', canvasImageUrl.value);
uni.hideLoading();
});
let drawArr = ref('');
let canvansWidth = ref(0); // 画布宽度
let canvansHeight = ref(0); // 画布高度
let ctx = null; // 画布
let canvasImageUrl = ref(''); // 生成的图片路径
// 初始化
function init() {
drawArr.value = JSON.parse(JSON.stringify(props.drawArr));
canvansWidth.value = props.width / 2;
canvansHeight.value = props.height / 2;
ctx = uni.createCanvasContext('myCanvas', example);
handleData();
// 给画板添加默认的背景色,如果不需要背景色,可以注释掉
ctx.setFillStyle('#fff');
ctx.fillRect(0, 0, canvansWidth.value, canvansHeight.value);
ctx.draw(true);
ctx.restore();
}
// 处理数据
function handleData() {
// 获取绘制元素的x,y坐标
for (let index = 0; index < drawArr.value.length; index++) {
let element = drawArr.value[index];
switch (element.type) {
case 'image':
case 'rect':
element.drawOptions.width = getWidth(element.drawOptions.width);
element.drawOptions.height = getHeight(element.drawOptions.height);
element.drawOptions.x = getX(element.drawOptions);
element.drawOptions.y = getY(element.drawOptions);
break;
case 'arc':
element.drawOptions.radius = element.drawOptions.radius / 2;
element.drawOptions.x = getArcX(element.drawOptions);
element.drawOptions.y = getArcY(element.drawOptions);
break;
case 'avatar':
element.drawOptions.width = getWidth(element.drawOptions.width);
element.drawOptions.height = getHeight(element.drawOptions.height);
element.drawOptions.radius = element.drawOptions.width / 2;
element.drawOptions.x = getArcX(element.drawOptions);
element.drawOptions.y = getArcY(element.drawOptions);
break;
case 'text':
element.drawOptions.fontSize = element.drawOptions.fontSize / 2 || 16;
element.drawOptions.maxLine = element.drawOptions.maxLine || 1;
element.drawOptions.lineHeight = element.drawOptions.lineHeight || 1.2;
element.drawOptions.italic = element.drawOptions.italic ? 'italic' : 'normal';
element.drawOptions.bold = element.drawOptions.bold ? 'bold' : 'normal';
element.drawOptions.fontFamily = element.drawOptions.fontFamily || 'PingFang SC';
element.drawOptions.maxWidth = element.drawOptions.maxWidth / 2 || canvansWidth.value;
// 特殊处理 改变文字颜色
element.drawOptions.changeColorList = element.drawOptions.specialTreatment.find(
(item) => item.key == 'changeTextColor'
)
? element.drawOptions.specialTreatment.find((item) => item.key == 'changeTextColor')
.list
: '';
// 特殊处理 换行符
element.drawOptions.lineFeed = element.drawOptions.specialTreatment.find(
(item) => item.key == 'lineFeed'
)
? element.drawOptions.specialTreatment.find((item) => item.key == 'lineFeed').text
: '';
break;
}
}
}
function getWidth(width) {
if (typeof width === 'number') {
return width / 2;
} else {
return canvansWidth.value * (width.match(/\d+/)[0] / 100);
}
}
function getHeight(height) {
if (typeof height === 'number') {
return height / 2;
} else {
return canvansHeight.value * (height.match(/\d+/)[0] / 100);
}
}
function getX(drawOptions) {
let { left, right, width } = drawOptions;
let x = '';
if (left || left === 0) {
if (typeof left === 'number') {
x = left / 2;
} else {
x = canvansWidth.value / 2 - width / 2;
}
} else {
x = canvansWidth.value - (right / 2 + width);
}
return x;
}
function getY(drawOptions) {
let { top, bottom, height } = drawOptions;
let y = '';
if (top || top === 0) {
if (typeof top === 'number') {
y = top / 2;
} else {
y = canvansHeight.value / 2 - height / 2;
}
} else {
y = canvansHeight.value - (bottom / 2 + height);
}
return y;
}
function getArcX(drawOptions) {
let { left, right, radius } = drawOptions;
let x = '';
if (left || left === 0) {
if (typeof left === 'number') {
x = left / 2 + radius;
} else {
x = canvansWidth.value / 2;
}
} else if (left === 0) {
x = radius;
} else {
x = canvansWidth.value - (right / 2 + radius);
}
return x;
}
function getArcY(drawOptions) {
let { top, bottom, radius } = drawOptions;
let y = '';
if (top) {
if (typeof top === 'number') {
y = top / 2 + radius;
} else {
y = canvansHeight.value / 2;
}
} else if (top === 0) {
y = radius;
} else {
y = canvansHeight.value - (bottom / 2 + radius);
}
return y;
}
// 开始绘制
async function startDrawing() {
for (let index = 0; index < drawArr.value.length; index++) {
const element = drawArr.value[index];
await draw(element);
}
}
// 绘制
async function draw(data) {
switch (data.type) {
case 'image':
let imageUrl = await getUrl(data.drawOptions.url);
drawImage(data, imageUrl);
break;
case 'text':
drawText(data);
break;
case 'rect':
drawRect(data);
break;
case 'arc':
drawArc(data);
break;
case 'avatar':
let avatarUrl = await getUrl(data.drawOptions.url);
drawAvatar(data, avatarUrl);
break;
}
}
// 获取图片url
function getUrl(url) {
return new Promise((resolve, reject) => {
uni.downloadFile({
url: url,
success: (res) => {
resolve(res.tempFilePath);
},
});
});
}
// 绘制图片
function drawImage(data, url) {
ctx.save();
let { x, y, width, height } = data.drawOptions;
ctx.drawImage(url, x, y, width, height);
ctx.draw(true);
ctx.restore();
}
// 绘制矩形
function drawRect(data) {
ctx.save();
let { x, y, width, height, type, fillStyle, strokeStyle, isFillet, radius } = data.drawOptions;
// 绘制圆角
if (isFillet) {
ctx.beginPath();
switch (type) {
case 'fill':
// ctx.fillStyle = 'transparent';
ctx.fillStyle = fillStyle;
break;
case 'stroke':
// ctx.strokeStyle = 'transparent';
ctx.strokeStyle = strokeStyle;
break;
}
// 左上角
ctx.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 1.5);
// 右上角
ctx.arc(x + width - radius, y + radius, radius, Math.PI * 1.5, Math.PI * 2);
// 右下角
ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI * 0.5);
// 左下角
ctx.arc(x + radius, y + height - radius, radius, Math.PI * 0.5, Math.PI);
ctx.closePath();
switch (type) {
case 'fill':
ctx.fill();
break;
case 'stroke':
ctx.stroke();
break;
}
ctx.clip();
}
// 绘制矩形
switch (type) {
case 'fill':
ctx.fillStyle = fillStyle;
ctx.fillRect(x, y, width, height);
break;
case 'stroke':
ctx.strokeStyle = strokeStyle;
ctx.strokeRect(x, y, width, height);
break;
}
ctx.draw(true);
ctx.restore();
}
// 绘制圆
function drawArc(data) {
ctx.save();
let { x, y, radius, type, fillStyle, strokeStyle } = data.drawOptions;
switch (type) {
case 'fill':
ctx.fillStyle = fillStyle;
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
break;
case 'stroke':
ctx.strokeStyle = strokeStyle;
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.stroke();
break;
}
ctx.draw(true);
ctx.restore();
}
// 绘制头像
function drawAvatar(data, url) {
ctx.save();
let { x, y, width, height, radius } = data.drawOptions;
// 绘制背景圆
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.clip();
// 绘制头像图片
ctx.drawImage(url, x - radius, y - radius, width, height);
ctx.draw(true);
ctx.restore();
}
// 绘制文字
function drawText(data) {
ctx.save();
let {
text,
maxLine,
maxWidth,
type,
fontSize,
lineHeight,
fillStyle,
strokeStyle,
italic,
bold,
fontFamily,
underlineOption,
deleteLineOption,
changeColorList,
lineFeed,
} = data.drawOptions;
ctx.font = `${italic} ${bold} ${fontSize}px ${fontFamily}`;
// 兼容部分iOS设备字号不生效问题
ctx.setFontSize(fontSize);
// 获取xy坐标轴一定要在样式后面,因为字号/加粗/斜体会影响宽度和高度
data.drawOptions.x = getTextX(data.drawOptions);
data.drawOptions.y = getTextY(data.drawOptions);
let { x, y } = data.drawOptions;
let handleTextArr = textHandle(text, maxLine, maxWidth, lineFeed);
for (let i = 0; i < handleTextArr.length; i++) {
if (type == 'fill') {
ctx.fillStyle = fillStyle;
} else {
ctx.strokeStyle = strokeStyle;
}
let ny = y + (i + 1) * (fontSize * lineHeight);
for (let index = 0; index < handleTextArr[i].length; index++) {
// 特殊处理 改变特定文字颜色 开始
if (changeColorList) {
let special = changeColorList.find((item) => item.text == handleTextArr[i][index]);
if (special) {
if (type == 'fill') {
ctx.fillStyle = special.color;
} else {
ctx.strokeStyle = special.color;
}
} else {
if (type == 'fill') {
ctx.fillStyle = fillStyle;
} else {
ctx.strokeStyle = strokeStyle;
}
}
}
// 特殊处理 改变特定文字颜色 结束
if (index) {
let lastTextW = ctx.measureText(handleTextArr[i].slice(0, index)).width;
if (type == 'fill') {
ctx.fillText(handleTextArr[i][index], x + lastTextW, ny);
} else {
ctx.strokeText(handleTextArr[i][index], x + lastTextW, ny);
}
} else {
if (type == 'fill') {
ctx.fillText(handleTextArr[i][index], x, ny);
} else {
ctx.strokeText(handleTextArr[i][index], x, ny);
}
}
}
// 绘制下划线
if (underlineOption) {
const textW = ctx.measureText(handleTextArr[i]).width;
ctx.beginPath();
ctx.lineWidth = underlineOption.size; // 设置下划线粗细
ctx.moveTo(x, ny + underlineOption.offset);
ctx.lineTo(x + textW, ny + underlineOption.offset);
ctx.strokeStyle = underlineOption.color;
ctx.stroke();
}
// 绘制删除线
if (deleteLineOption) {
const textW = ctx.measureText(handleTextArr[i]).width;
ctx.beginPath();
ctx.lineWidth = deleteLineOption.size; // 设置删除线粗细
ctx.moveTo(x, ny - fontSize / 2 + deleteLineOption.size);
ctx.lineTo(x + textW, ny - fontSize / 2 + deleteLineOption.size);
ctx.strokeStyle = deleteLineOption.color;
ctx.stroke();
}
}
ctx.draw(true);
ctx.restore();
}
// 文本处理
function textHandle(text, maxLine, maxWidth, lineFeed) {
let rowLength = 0;
let textStr = '';
let textArr = [];
for (let index = 0; index < text.length; index++) {
if (textArr.length == maxLine) break;
let t = text[index];
let tWidth = ctx.measureText(t).width;
// 特殊处理 换行符 开始
if (lineFeed) {
// 检查是否是特殊字符,如果是则强制换行,但不包含特殊字符本身
if (t === lineFeed) {
if (textStr) {
textArr.push(textStr);
textStr = '';
rowLength = 0;
}
continue; // 跳过特殊字符,不将其加入文本中
}
}
// 特殊处理 换行符 结束
textStr += t;
rowLength += tWidth;
if (rowLength == maxWidth) {
textArr.push(textStr);
textStr = '';
rowLength = 0;
} else if (rowLength > maxWidth) {
textStr = textStr.slice(0, -1);
textArr.push(textStr);
textStr = t;
rowLength = tWidth;
}
}
textArr.push(textStr);
if (textArr.length > maxLine) {
textArr[maxLine - 1] = textArr[maxLine - 1].slice(0, -1) + '...';
textArr = textArr.slice(0, maxLine);
}
return textArr;
}
function getTextX(drawOptions) {
let { left, right, text, maxLine, maxWidth, lineFeed } = drawOptions;
let x = '';
if (left || left === 0) {
if (typeof left === 'number') {
x = left / 2;
} else {
let rowLength = getTextWidth(text, maxLine, maxWidth, lineFeed);
x = canvansWidth.value / 2 - rowLength / 2;
}
} else {
let rowLength = getTextWidth(text, maxLine, maxWidth, lineFeed);
x = canvansWidth.value - (right / 2 + rowLength);
}
function getTextWidth(text, maxLine, maxWidth, lineFeed) {
let textArr = textHandle(text, maxLine, maxWidth, lineFeed);
let textItem = textArr[0];
let rowLength = 0;
for (let index = 0; index < textItem.length; index++) {
const t = textItem[index];
rowLength += ctx.measureText(t).width;
}
rowLength = Math.floor(rowLength);
if (rowLength > maxWidth) {
rowLength = maxWidth;
}
return rowLength;
}
return x;
}
function getTextY(drawOptions) {
let { top, bottom, text, fontSize, lineHeight, maxLine, maxWidth, lineFeed } = drawOptions;
let y = '';
if (top || top === 0) {
if (typeof top === 'number') {
y = top / 2;
} else {
let textHeight = getTextHeight(text, maxLine, maxWidth, fontSize, lineHeight, lineFeed);
y = canvansHeight.value / 2 - textHeight / 2;
}
} else {
let textHeight = getTextHeight(text, maxLine, maxWidth, fontSize, lineHeight, lineFeed);
y = canvansHeight.value - (bottom / 2 + textHeight);
}
function getTextHeight(text, maxLine, maxWidth, fontSize, lineHeight, lineFeed) {
let textArr = textHandle(text, maxLine, maxWidth, lineFeed);
let textHeight = textArr.length * (fontSize * lineHeight);
return textHeight;
}
return y;
}
// 下载图片
function getCanvasImageUrl() {
return new Promise((resolve, reject) => {
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId: 'myCanvas',
success: (res) => {
// console.log('转换图片成功', res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (err) => {
console.error('转换图片失败', err);
reject(err);
},
},
example
);
}, 500);
});
}
</script>
<style lang="scss" scoped>
.canvas-box {
position: relative;
.myCanvas {
position: absolute;
left: -200%;
top: -200%;
}
image {
width: 100%;
height: 100%;
}
}
</style>
页面使用
<myCanvas
:drawArr="drawArr"
:width="690"
:height="996"
@canvasDrawComplete="canvasDrawComplete"
></myCanvas>
<button @click="downloadCanvasImageUrl">{{ canvasImageUrl ? '保存图片' : '绘制中...' }}</button>
import myCanvas from './myCanvas.vue';
let drawArr = ref([]);
let canvasImageUrl = ref(''); // canvas画布转换成图片的url
function canvasDrawComplete(url) {
canvasImageUrl.value = url;
}
function downloadCanvasImageUrl() {
if (!canvasImageUrl.value) return;
uni.saveImageToPhotosAlbum({
filePath: canvasImageUrl.value,
success: () => {
uni.showToast({ title: '保存成功', icon: 'none' });
},
fail: (err) => {
uni.showToast({ title: '保存失败', icon: 'none' });
},
});
}
注意:drawArr这个数组中保存的是需要渲染的元素,但是这个渲染的元素有渲染顺序,因为后渲染的会覆盖先渲染的(位置一样的情况),就像画画一样,后面画的肯定会挡住前面画的(在同一个位置作画的情况)
实例:比如我先在画布上渲染了一行文字,然后我又在画布上渲染了一个图片,这个图片的大小是画布的大小,这就会导致文字渲染不出来了
所以,使用时要判断一下元素的渲染顺序,再根据渲染顺序来写入drawArr这个数组的值