forked from angelo/web-retail-h5
529 lines
18 KiB
Vue
529 lines
18 KiB
Vue
<template>
|
||
<view class="canvas">
|
||
<view ref="Content" class="theContent">
|
||
<slot></slot>
|
||
</view>
|
||
<canvas canvas-id="myCanvas" id="myCanvas" @onReady="onCanvasReady"
|
||
:style="{ width: width + 'px', height: height + 'px' }"></canvas>
|
||
</view>
|
||
</template>
|
||
<!--
|
||
list参数说明:
|
||
图片渲染:
|
||
type: 'image',
|
||
x: X轴位置,
|
||
y: Y轴位置,
|
||
path: 图片路径,
|
||
width: 图片宽度,
|
||
height: 图片高度,
|
||
rotate: 旋转角度
|
||
mode:'center' 居中自适应
|
||
shape: 形状,默认无,可选值:circle 圆形 如果是number,则生成圆角矩形
|
||
area: {x,y,width,height} // 绘制范围,超出该范围会被剪裁掉 该属性与shape暂时无法同时使用,area存在时,shape失效
|
||
矩形渲染:
|
||
type: 'square',
|
||
x: X轴位置,
|
||
y: Y轴位置,
|
||
width: 图片宽度,
|
||
height: 图片高度,
|
||
rotate: 旋转角度
|
||
shape: 形状,默认无,可选值:circle 圆形 如果是number,则生成圆角矩形
|
||
fillStyle:填充的背景样式
|
||
area: {x,y,width,height} // 绘制范围,超出该范围会被剪裁掉 该属性与shape暂时无法同时使用,area存在时,shape失效
|
||
文字渲染:
|
||
type: 'text',
|
||
x: X轴位置,
|
||
y: Y轴位置,
|
||
text: 文本内容,
|
||
size: 字体大小,
|
||
textBaseline: 基线 默认top 可选值:'top'、'bottom'、'middle'、'normal'
|
||
color: 颜色
|
||
多行文字渲染:
|
||
type: 'textarea',
|
||
x: X轴位置,
|
||
y: Y轴位置,
|
||
width:换行的宽度
|
||
height: 高度,溢出会展示“...”
|
||
lineSpace: 行间距
|
||
text: 文本内容,
|
||
size: 字体大小,
|
||
textBaseline: 基线 默认top 可选值:'top'、'bottom'、'middle'、'normal'
|
||
color: 颜色
|
||
-->
|
||
<script>
|
||
let timer = null;
|
||
export default {
|
||
name: "Poster",
|
||
props: {
|
||
// 绘制队列
|
||
list: {
|
||
type: Array,
|
||
required: true,
|
||
},
|
||
// 海报宽度(默认设备宽度放大两倍) 建议都放大两倍
|
||
width: {
|
||
type: [Number, String],
|
||
default: uni.getSystemInfoSync().windowWidth * 2,
|
||
},
|
||
// 海报高度(默认设备高度放大两倍)
|
||
height: {
|
||
type: [Number, String],
|
||
default: uni.getSystemInfoSync().windowHeight * 2,
|
||
},
|
||
//背景颜色
|
||
backgroundColor: {
|
||
type: String,
|
||
default: "rgba(0,0,0,0)",
|
||
},
|
||
},
|
||
data() {
|
||
return {
|
||
posterUrl: "",
|
||
renderList: [],
|
||
ctx: null, //画布上下文
|
||
counter: -1, //计数器
|
||
drawPathQueue: [], //画图路径队列
|
||
};
|
||
},
|
||
watch: {
|
||
drawPathQueue(newVal, oldVal) {
|
||
// 绘制单行文字
|
||
const fillText = (textOptions) => {
|
||
this.ctx.setFillStyle(textOptions.color);
|
||
this.ctx.setFontSize(textOptions.size);
|
||
this.ctx.setTextBaseline(textOptions.textBaseline || "top");
|
||
this.ctx.fillText(textOptions.text, textOptions.x, textOptions.y);
|
||
};
|
||
// 绘制段落
|
||
const fillParagraph = (textOptions) => {
|
||
this.ctx.setFontSize(textOptions.size);
|
||
let tempOptions = JSON.parse(JSON.stringify(textOptions));
|
||
// 如果没有指定行间距则设置默认值
|
||
tempOptions.lineSpace = tempOptions.lineSpace ? tempOptions.lineSpace : 10;
|
||
// 获取字符串
|
||
let str = textOptions.text;
|
||
// 计算指定高度可以输出的最大行数
|
||
let lineCount = Math.floor((tempOptions.height + tempOptions.lineSpace) / (tempOptions.size +
|
||
tempOptions.lineSpace));
|
||
// 初始化单行宽度
|
||
let lineWidth = 0;
|
||
let lastSubStrIndex = 0; //每次开始截取的字符串的索引
|
||
|
||
// 构建一个打印数组
|
||
let strArr = str.split("");
|
||
let drawArr = [];
|
||
let text = "";
|
||
while (strArr.length) {
|
||
let word = strArr.shift();
|
||
text += word;
|
||
let textWidth = this.ctx.measureText(text).width;
|
||
if (textWidth > textOptions.width) {
|
||
// 因为超出宽度 所以要截取掉最后一个字符
|
||
text = text.substr(0, text.length - 1);
|
||
drawArr.push(text);
|
||
text = "";
|
||
// 最后一个字还给strArr
|
||
strArr.unshift(word);
|
||
} else if (!strArr.length) {
|
||
drawArr.push(text);
|
||
}
|
||
}
|
||
|
||
if (drawArr.length > lineCount) {
|
||
// 超出最大行数
|
||
drawArr.length = lineCount;
|
||
let pointWidth = this.ctx.measureText("...").width;
|
||
let wordWidth = 0;
|
||
let wordArr = drawArr[drawArr.length - 1].split("");
|
||
let words = "";
|
||
while (pointWidth > wordWidth) {
|
||
words += wordArr.pop();
|
||
wordWidth = this.ctx.measureText(words).width;
|
||
}
|
||
drawArr[drawArr.length - 1] = wordArr.join("") + "...";
|
||
}
|
||
// 打印
|
||
for (let i = 0; i < drawArr.length; i++) {
|
||
let _h = i > 0 ? tempOptions.size + tempOptions.lineSpace : 0;
|
||
tempOptions.y = tempOptions.y + _h; // y的位置
|
||
tempOptions.text = drawArr[i]; // 绘制的文本
|
||
fillText(tempOptions);
|
||
}
|
||
};
|
||
const roundRect = (x, y, w, h, r) => {
|
||
// 开始绘制
|
||
this.ctx.beginPath();
|
||
// 因为边缘描边存在锯齿,最好指定使用 transparent 填充
|
||
// 这里是使用 fill 还是 stroke都可以,二选一即可
|
||
this.ctx.setFillStyle("transparent");
|
||
// ctx.setStrokeStyle('transparent')
|
||
// 左上角
|
||
this.ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5);
|
||
// border-top
|
||
this.ctx.moveTo(x + r, y);
|
||
this.ctx.lineTo(x + w - r, y);
|
||
this.ctx.lineTo(x + w, y + r);
|
||
// 右上角
|
||
this.ctx.arc(x + w - r, y + r, r, Math.PI * 1.5, Math.PI * 2);
|
||
|
||
// border-right
|
||
this.ctx.lineTo(x + w, y + h - r);
|
||
this.ctx.lineTo(x + w - r, y + h);
|
||
// 右下角
|
||
this.ctx.arc(x + w - r, y + h - r, r, 0, Math.PI * 0.5);
|
||
|
||
// border-bottom
|
||
this.ctx.lineTo(x + r, y + h);
|
||
this.ctx.lineTo(x, y + h - r);
|
||
// 左下角
|
||
this.ctx.arc(x + r, y + h - r, r, Math.PI * 0.5, Math.PI);
|
||
// border-left
|
||
this.ctx.lineTo(x, y + r);
|
||
this.ctx.lineTo(x + r, y);
|
||
// 这里是使用 fill 还是 stroke都可以,二选一即可,但是需要与上面对应
|
||
this.ctx.fill();
|
||
// ctx.stroke()
|
||
this.ctx.closePath();
|
||
// 剪切
|
||
this.ctx.clip();
|
||
};
|
||
// 图片自适应
|
||
const adaptiveImg = (img, x, y, mode) => {
|
||
const {
|
||
imgW: w,
|
||
imgH: h,
|
||
width: dWidth,
|
||
height: dHeight
|
||
} = img;
|
||
let dw = dWidth / w; //canvas与图片的宽比
|
||
let dh = dHeight / h; //canvas与图片的高比
|
||
// 裁剪图片中间部分
|
||
if ((w > dWidth && h > dHeight) || (w < dWidth && h < dHeight)) {
|
||
if (dw > dh) {
|
||
this.ctx.drawImage(img.path, 0, (h - dHeight / dw) / 2, w, dHeight / dw, x, y, dWidth,
|
||
dHeight);
|
||
} else {
|
||
this.ctx.drawImage(img.path, (w - dWidth / dh) / 2, 0, dWidth / dh, h, x, y, dWidth,
|
||
dHeight);
|
||
}
|
||
} else {
|
||
// 拉伸图片
|
||
if (w < dWidth) {
|
||
this.ctx.drawImage(img.path, 0, (h - dHeight / dw) / 2, w, dHeight / dw, x, y, dWidth,
|
||
dHeight);
|
||
} else {
|
||
this.ctx.drawImage(img.path, (w - dWidth / dh) / 2, 0, dWidth / dh, h, x, y, dWidth,
|
||
dHeight);
|
||
}
|
||
}
|
||
// 裁剪图片中间部分
|
||
// this.ctx.drawImage(img.path, sx, sy, sWidth, sHeight, x, y, dWidth, dHeight);
|
||
// this.ctx.drawImage(img.path, x, y, temp.dWidth, temp.dHeight);
|
||
};
|
||
// 绘制背景
|
||
this.ctx.setFillStyle(this.backgroundColor);
|
||
this.ctx.fillRect(0, 0, this.width, this.height);
|
||
/* 所有元素入队则开始绘制 */
|
||
if (newVal.length === this.renderList.length) {
|
||
if (newVal.length == 0) {
|
||
this.$emit("on-error", {
|
||
msg: "数据为空",
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
// console.log('生成的队列:' + JSON.stringify(newVal));
|
||
console.log("开始绘制...");
|
||
for (let i = 0; i < this.drawPathQueue.length; i++) {
|
||
for (let j = 0; j < this.drawPathQueue.length; j++) {
|
||
let current = this.drawPathQueue[j];
|
||
/* 按顺序绘制 */
|
||
if (current.index === i) {
|
||
/* 文本绘制 */
|
||
if (current.type === "text") {
|
||
console.log("开始绘制...text");
|
||
fillText(current);
|
||
this.counter--;
|
||
}
|
||
/* 多行文本 */
|
||
if (current.type === "textarea") {
|
||
console.log("开始绘制...textarea");
|
||
fillParagraph(current);
|
||
this.counter--;
|
||
}
|
||
/* 多行文本 */
|
||
if (current.type === "square") {
|
||
console.log("开始绘制...square");
|
||
this.ctx.save(); // 保存上下文,绘制后恢复
|
||
this.ctx.beginPath(); //开始绘制
|
||
//画好了圆 剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
|
||
// 设置旋转中心
|
||
let offsetX = current.x + Number(current.width) / 2;
|
||
let offsetY = current.y + Number(current.height) / 2;
|
||
this.ctx.translate(offsetX, offsetY);
|
||
let degrees = current.rotate ? Number(current.rotate) % 360 : 0;
|
||
this.ctx.rotate((degrees * Math.PI) / 180);
|
||
this.ctx.fillStyle = current.fillStyle;
|
||
this.ctx.fillRect(current.x - offsetX, current.y - offsetY, current.width, current
|
||
.height);
|
||
this.ctx.closePath();
|
||
this.ctx.restore(); // 恢复之前保存的上下文
|
||
this.counter--;
|
||
}
|
||
/* 图片绘制 */
|
||
if (current.type === "image") {
|
||
console.log("开始绘制...image");
|
||
if (current.area) {
|
||
// 绘制绘图区域
|
||
this.ctx.save();
|
||
this.ctx.beginPath(); //开始绘制
|
||
this.ctx.rect(current.area.x, current.area.y, current.area.width, current.area
|
||
.height);
|
||
this.ctx.clip();
|
||
// 设置旋转中心
|
||
let offsetX = current.x + Number(current.width) / 2;
|
||
let offsetY = current.y + Number(current.height) / 2;
|
||
this.ctx.translate(offsetX, offsetY);
|
||
let degrees = current.rotate ? Number(current.rotate) % 360 : 0;
|
||
this.ctx.rotate((degrees * Math.PI) / 180);
|
||
this.ctx.drawImage(current.path, current.x - offsetX, current.y - offsetY,
|
||
current.width, current.height);
|
||
this.ctx.closePath();
|
||
this.ctx.restore(); // 恢复之前保存的上下文
|
||
} else if (current.shape == "circle") {
|
||
this.ctx.save(); // 保存上下文,绘制后恢复
|
||
this.ctx.beginPath(); //开始绘制
|
||
//先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针
|
||
let width = current.width / 2 + current.x;
|
||
let height = current.height / 2 + current.y;
|
||
let r = current.width / 2;
|
||
this.ctx.arc(width, height, r, 0, Math.PI * 2);
|
||
this.ctx.lineTo(current.x, current.y);
|
||
this.ctx.fill();
|
||
//画好了圆 剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
|
||
this.ctx.clip();
|
||
// 设置旋转中心
|
||
let offsetX = current.x + Number(current.width) / 2;
|
||
let offsetY = current.y + Number(current.height) / 2;
|
||
this.ctx.translate(offsetX, offsetY);
|
||
let degrees = current.rotate ? Number(current.rotate) % 360 : 0;
|
||
this.ctx.rotate((degrees * Math.PI) / 180);
|
||
current.mode ?
|
||
adaptiveImg(current, current.x - offsetX, current.y - offsetY, current
|
||
.mode) :
|
||
this.ctx.drawImage(current.path, current.x - offsetX, current.y - offsetY,
|
||
current.width, current.height);
|
||
this.ctx.closePath();
|
||
this.ctx.restore(); // 恢复之前保存的上下文
|
||
} else if (typeof current.shape == "number") {
|
||
this.ctx.save(); // 保存上下文,绘制后恢复
|
||
this.ctx.beginPath(); //开始绘制
|
||
roundRect(current.x, current.y, current.width, current.height, current.shape);
|
||
//画好了圆 剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
|
||
// 设置旋转中心
|
||
let offsetX = current.x + Number(current.width) / 2;
|
||
let offsetY = current.y + Number(current.height) / 2;
|
||
this.ctx.translate(offsetX, offsetY);
|
||
let degrees = current.rotate ? Number(current.rotate) % 360 : 0;
|
||
this.ctx.rotate((degrees * Math.PI) / 180);
|
||
current.mode ?
|
||
adaptiveImg(current, current.x - offsetX, current.y - offsetY, current
|
||
.mode) :
|
||
this.ctx.drawImage(current.path, current.x - offsetX, current.y - offsetY,
|
||
current.width, current.height);
|
||
this.ctx.closePath();
|
||
this.ctx.restore(); // 恢复之前保存的上下文
|
||
} else {
|
||
this.ctx.drawImage(current.path, current.x, current.y, current.width, current
|
||
.height);
|
||
}
|
||
this.counter--;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.log(err);
|
||
this.$emit("on-error", err);
|
||
}
|
||
}
|
||
},
|
||
counter(newVal, oldVal) {
|
||
if (newVal === 0) {
|
||
this.ctx.draw(false, (draw) => {
|
||
uni.canvasToTempFilePath({
|
||
canvasId: "myCanvas",
|
||
success: (res) => {
|
||
// 在H5平台下,tempFilePath 为 base64
|
||
console.log("图片已保存至本地:", res.tempFilePath);
|
||
this.posterUrl = res.tempFilePath;
|
||
this.$emit("on-success", res.tempFilePath);
|
||
},
|
||
fail: (error) => {
|
||
this.$emit("on-error", error);
|
||
},
|
||
},
|
||
this
|
||
);
|
||
});
|
||
}
|
||
},
|
||
},
|
||
mounted() {
|
||
// this.generateImg();
|
||
// console.log('mounted');
|
||
},
|
||
methods: {
|
||
onCanvasReady() {
|
||
// #ifdef MP-ALIPAY
|
||
const query = my.createSelectorQuery();
|
||
query
|
||
.select("#myCanvas")
|
||
.node()
|
||
.exec((res) => {
|
||
const canvas = res[0].node;
|
||
const ctx = canvas.getContext("2d");
|
||
});
|
||
// #endif
|
||
},
|
||
/**
|
||
* @param {*} elClass 元素名称
|
||
* @param {*} slot 是否采用slot方式
|
||
* @param {*} startX X偏移
|
||
* @param {*} startY Y偏移
|
||
* @return {*}
|
||
*/
|
||
createForElRect(elClass = "Poster", slot = true, startX = 0, startY = 0) {
|
||
uni
|
||
.createSelectorQuery()
|
||
.selectAll("." + elClass)
|
||
.fields({
|
||
dataset: true,
|
||
size: true,
|
||
rect: true,
|
||
value: true,
|
||
scrollOffset: true,
|
||
properties: ["src", "mode"],
|
||
computedStyle: ["margin", "padding", "backgroundColor", "fontSize", "color", "fontWeight",
|
||
"borderRadius"
|
||
],
|
||
context: true,
|
||
},
|
||
(res) => {
|
||
let list = [];
|
||
const sys = uni.getSystemInfoSync();
|
||
let multiple = sys.windowWidth / this.width;
|
||
res.forEach((val, index) => {
|
||
let src = val.src || val.dataset.enode || "";
|
||
let type = val.src ? "image" : val.dataset.etype || "text";
|
||
let text = val.dataset.enode || "";
|
||
let size = val.fontSize.replace("px", "") || 13;
|
||
let shape = val.borderRadius == "50%" ? "circle" : val.borderRadius.replace("px",
|
||
"") * 2;
|
||
let x = (startX + val.left - (slot ? sys.screenWidth : 0)) / multiple;
|
||
let y = (startY + val.top) / multiple;
|
||
y = (startY + val.top - (slot ? 50 : 0)) / multiple;
|
||
// #ifdef H5
|
||
y = (startY + val.top) / multiple;
|
||
// #endif
|
||
list.push({
|
||
type: type,
|
||
shape,
|
||
text,
|
||
mode: "center",
|
||
x,
|
||
y,
|
||
path: src,
|
||
width: val.width / multiple,
|
||
height: val.height / multiple,
|
||
size: size / multiple,
|
||
color: val.color,
|
||
});
|
||
});
|
||
let canvas = uni.createCanvasContext("myCanvas", this);
|
||
this.ctx = canvas;
|
||
this.renderList = [...this.list, ...list];
|
||
this.generateImg();
|
||
}
|
||
)
|
||
.exec();
|
||
},
|
||
create() {
|
||
let canvas = uni.createCanvasContext("myCanvas", this);
|
||
this.ctx = canvas;
|
||
this.renderList = this.list;
|
||
this.generateImg();
|
||
},
|
||
async generateImg() {
|
||
console.log("generateimg");
|
||
this.counter = this.renderList.length;
|
||
this.drawPathQueue = [];
|
||
const getImgInfo = async (current) => {
|
||
// console.log("current", current);
|
||
return new Promise((resolve, reject) => {
|
||
uni.getImageInfo ?
|
||
uni.getImageInfo({
|
||
src: current.path,
|
||
success: (res) => {
|
||
current.path = res.path;
|
||
current.imgW = res.width;
|
||
current.imgH = res.height;
|
||
// this.drawPathQueue.push(current);
|
||
resolve(current);
|
||
},
|
||
}) :
|
||
resolve(current);
|
||
});
|
||
};
|
||
const delayedLog = async (v, i) => {
|
||
let current = this.renderList[i];
|
||
current.index = i;
|
||
/* 如果是文本直接放入队列 */
|
||
if (current.type === "text" || current.type === "textarea" || current.type === "square") {
|
||
// this.drawPathQueue.push(current);
|
||
return current;
|
||
} else {
|
||
return await getImgInfo(current);
|
||
/* 图片需获取本地缓存path放入队列 */
|
||
}
|
||
};
|
||
const processArray = async (array) => {
|
||
// map array to promises
|
||
const promises = array.map((v, i) => delayedLog(v, i));
|
||
// wait until all promises are resolved
|
||
const allData = await Promise.all(promises);
|
||
console.log("Done!", allData);
|
||
return allData;
|
||
};
|
||
this.drawPathQueue = await processArray(this.renderList);
|
||
},
|
||
saveImg() {
|
||
uni.canvasToTempFilePath({
|
||
canvasId: "myCanvas",
|
||
success: (res) => {
|
||
// 在H5平台下,tempFilePath 为 base64
|
||
uni.saveImageToPhotosAlbum({
|
||
filePath: res.tempFilePath,
|
||
success: () => {
|
||
console.log("save success");
|
||
},
|
||
});
|
||
},
|
||
},
|
||
this
|
||
);
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.canvas {
|
||
position: fixed;
|
||
top: 100rpx;
|
||
left: 750rpx;
|
||
width: 100vw;
|
||
}
|
||
|
||
.theContent {}
|
||
</style> |