hookehuyr

✨ feat: 新增生成canvas海报控件,调试分享海报功能

......@@ -2,7 +2,7 @@
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-05-27 15:57:59
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-09-26 14:37:22
* @LastEditTime: 2022-09-27 09:41:31
* @FilePath: /swx/src/app.config.js
* @Description:
*/
......@@ -24,6 +24,7 @@ export default {
'pages/my/index',
'pages/createActivity/index',
'pages/activityDetail/index',
'pages/post/index',
],
subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去
{
......
<template>
<canvas
type="2d"
:id="canvasId"
:style="`height: ${height}rpx; width:${width}rpx;
position: absolute;
${debug ? '' : 'transform:translate3d(-9999rpx, 0, 0)'}`"
/>
</template>
<script lang="ts">
import Taro from "@tarojs/taro"
import { defineComponent, onMounted, PropType, ref } from "vue"
import { Image, DrawConfig } from "./types"
import { drawImage, drawText, drawBlock, drawLine } from "./utils/draw"
import {
toPx,
toRpx,
getRandomId,
getImageInfo,
getLinearColor,
} from "./utils/tools"
export default defineComponent({
name: "PosterBuilder",
props: {
showLoading: {
type: Boolean,
default: false,
},
config: {
type: Object as PropType<DrawConfig>,
default: () => ({}),
},
},
emits: ["success", "fail"],
setup(props, context) {
const count = ref(1)
const {
width,
height,
backgroundColor,
texts = [],
blocks = [],
lines = [],
debug = false,
} = props.config || {}
const canvasId = getRandomId()
/**
* step1: 初始化图片资源
* @param {Array} images = imgTask
* @return {Promise} downloadImagePromise
*/
const initImages = (images: Image[]) => {
const imagesTemp = images.filter((item) => item.url)
const drawList = imagesTemp.map((item, index) =>
getImageInfo(item, index)
)
return Promise.all(drawList)
}
/**
* step2: 初始化 canvas && 获取其 dom 节点和实例
* @return {Promise} resolve 里返回其 dom 和实例
*/
const initCanvas = () =>
new Promise<any>((resolve) => {
setTimeout(() => {
const pageInstance = Taro.getCurrentInstance()?.page || {} // 拿到当前页面实例
const query = Taro.createSelectorQuery().in(pageInstance) // 确定在当前页面内匹配子元素
query
.select(`#${canvasId}`)
.fields({ node: true, size: true, context: true }, (res) => {
const canvas = res.node
const ctx = canvas.getContext("2d")
resolve({ ctx, canvas })
})
.exec()
}, 300)
})
/**
* @description 保存绘制的图片
* @param { object } config
*/
const getTempFile = (canvas) => {
Taro.canvasToTempFilePath(
{
canvas,
success: (result) => {
Taro.hideLoading()
context.emit("success", result)
},
fail: (error) => {
const { errMsg } = error
if (errMsg === "canvasToTempFilePath:fail:create bitmap failed") {
count.value += 1
if (count.value <= 3) {
getTempFile(canvas)
} else {
Taro.hideLoading()
Taro.showToast({
icon: "none",
title: errMsg || "绘制海报失败",
})
context.emit("fail", errMsg)
}
}
},
},
context
)
}
/**
* step2: 开始绘制任务
* @param { Array } drawTasks 待绘制任务
*/
const startDrawing = async (drawTasks) => {
// TODO: check
// const configHeight = getHeight(config)
const { ctx, canvas } = await initCanvas()
canvas.width = width
canvas.height = height
// 设置画布底色
if (backgroundColor) {
ctx.save() // 保存绘图上下文
const grd = getLinearColor(ctx, backgroundColor, 0, 0, width, height)
ctx.fillStyle = grd // 设置填充颜色
ctx.fillRect(0, 0, width, height) // 填充一个矩形
ctx.restore() // 恢复之前保存的绘图上下文
}
// 将要画的方块、文字、线条放进队列数组
const queue = drawTasks
.concat(
texts.map((item) => {
item.type = "text"
item.zIndex = item.zIndex || 0
return item
})
)
.concat(
blocks.map((item) => {
item.type = "block"
item.zIndex = item.zIndex || 0
return item
})
)
.concat(
lines.map((item) => {
item.type = "line"
item.zIndex = item.zIndex || 0
return item
})
)
queue.sort((a, b) => a.zIndex - b.zIndex) // 按照层叠顺序由低至高排序, 先画低的,再画高的
for (let i = 0; i < queue.length; i++) {
const drawOptions = {
canvas,
ctx,
toPx,
toRpx,
}
if (queue[i].type === "image") {
await drawImage(queue[i], drawOptions)
} else if (queue[i].type === "text") {
drawText(queue[i], drawOptions)
} else if (queue[i].type === "block") {
drawBlock(queue[i], drawOptions)
} else if (queue[i].type === "line") {
drawLine(queue[i], drawOptions)
}
}
setTimeout(() => {
getTempFile(canvas) // 需要做延时才能能正常加载图片
}, 300)
}
// start: 初始化 canvas 实例 && 下载图片资源
const init = () => {
if (props.showLoading)
Taro.showLoading({ mask: true, title: "生成中..." })
if (props.config?.images?.length) {
initImages(props.config.images)
.then((result) => {
// 1. 下载图片资源
startDrawing(result)
})
.catch((err) => {
Taro.hideLoading()
Taro.showToast({
icon: "none",
title: err.errMsg || "下载图片失败",
})
context.emit("fail", err)
})
} else {
startDrawing([])
}
}
onMounted(() => {
init()
})
return {
canvasId,
debug,
width,
height,
}
},
})
</script>
export type DrawType = 'text' | 'image' | 'block' | 'line';
export interface Block {
type?: DrawType;
x: number;
y: number;
width?: number;
height: number;
paddingLeft?: number;
paddingRight?: number;
borderWidth?: number;
borderColor?: string;
backgroundColor?: string;
borderRadius?: number;
borderRadiusGroup?: number[];
text?: Text;
opacity?: number;
zIndex?: number;
}
export interface Text {
type?: DrawType;
x?: number;
y?: number;
text: string | Text[];
fontSize?: number;
color?: string;
opacity?: 1 | 0;
lineHeight?: number;
lineNum?: number;
width?: number;
marginTop?: number;
marginLeft?: number;
marginRight?: number;
textDecoration?: 'line-through' | 'none';
baseLine?: 'top' | 'middle' | 'bottom';
textAlign?: 'left' | 'center' | 'right';
fontFamily?: string;
fontWeight?: string;
fontStyle?: string;
zIndex?: number;
}
export interface Image {
type?: DrawType;
x: number;
y: number;
url: string;
width: number;
height: number;
borderRadius?: number;
borderRadiusGroup?: number[];
borderWidth?: number;
borderColor?: string;
zIndex?: number;
}
export interface Line {
type?: DrawType;
startX: number;
startY: number;
endX: number;
endY: number;
width: number;
color?: string;
zIndex?: number;
}
export type DrawConfig = {
width: number;
height: number;
backgroundColor?: string;
debug?: boolean;
blocks?: Block[];
texts?: Text[];
images?: Image[];
lines?: Line[];
};
/* eslint-disable no-underscore-dangle */
import { getLinearColor, getTextX, toPx } from './tools';
/**
* 绘制圆角矩形
* @param { object } drawData - 绘制数据
* @param { number } drawData.x - 左上角x坐标
* @param { number } drawData.y - 左上角y坐标
* @param { number } drawData.w - 矩形的宽
* @param { number } drawData.h - 矩形的高
* @param { number } drawData.r - 圆角半径
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
* @description arcTo 比 arc 更加简洁,三点画弧,但是比较难理解 参考资料:http://www.yanghuiqing.com/web/346
* ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise(是否逆时针画弧))
* ctx.arcTo(x1, y1, x2, y2, radius); // 当前点-x1点 画切线 x1点到x2点画切线, 用半径为radius的圆弧替换掉切线部分
*/
export function _drawRadiusRect({ x, y, w, h, r }, { ctx }) {
const minSize = Math.min(w, h);
if (r > minSize / 2) r = minSize / 2;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r); // 绘制上边框和右上角弧线
ctx.arcTo(x + w, y + h, x, y + h, r); // 绘制右边框和右下角弧线
ctx.arcTo(x, y + h, x, y, r); // 绘制下边框和左下角弧线
ctx.arcTo(x, y, x + w, y, r); // 绘制左边框和左上角弧线
ctx.closePath();
}
/**
* 绘制圆角矩形
* @param { object } drawData - 绘制数据
* @param { number } drawData.x - 左上角x坐标
* @param { number } drawData.y - 左上角y坐标
* @param { number } drawData.w - 矩形的宽
* @param { number } drawData.h - 矩形的高
* @param { number } drawData.g - 圆角半径数组
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export function _drawRadiusGroupRect({ x, y, w, h, g }, { ctx }) {
const [
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius
] = g;
ctx.beginPath();
ctx.arc(
x + w - borderBottomRightRadius,
y + h - borderBottomRightRadius,
borderBottomRightRadius,
0,
Math.PI * 0.5
);
ctx.lineTo(x + borderBottomLeftRadius, y + h);
// 左下角
ctx.arc(
x + borderBottomLeftRadius,
y + h - borderBottomLeftRadius,
borderBottomLeftRadius,
Math.PI * 0.5,
Math.PI
);
ctx.lineTo(x, y + borderTopLeftRadius);
// 左上角
ctx.arc(
x + borderTopLeftRadius,
y + borderTopLeftRadius,
borderTopLeftRadius,
Math.PI,
Math.PI * 1.5
);
ctx.lineTo(x + w - borderTopRightRadius, y);
// 右上角
ctx.arc(
x + w - borderTopRightRadius,
y + borderTopRightRadius,
borderTopRightRadius,
Math.PI * 1.5,
Math.PI * 2
);
ctx.lineTo(x + w, y + h - borderBottomRightRadius);
// ctx.arcTo(x + w, y, x + w, y + h, r); // 绘制上边框和右上角弧线
// ctx.arcTo(x + w, y + h, x, y + h, r); // 绘制右边框和右下角弧线
// ctx.arcTo(x, y + h, x, y, r); // 绘制下边框和左下角弧线
// ctx.arcTo(x, y, x + w, y, r); // 绘制左边框和左上角弧线
ctx.closePath();
}
/**
* 计算文本长度
* @param { Array | Object } text 数组 或者 对象
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export function _getTextWidth(text, drawOptions) {
const { ctx } = drawOptions;
let texts: any[] = [];
if (Object.prototype.toString.call(text) === '[object Object]') {
texts.push(text);
} else {
texts = text;
}
let width = 0;
texts.forEach(
({
fontSize,
text: textStr,
fontStyle = 'normal',
fontWeight = 'normal',
fontFamily = 'sans-serif',
marginLeft = 0,
marginRight = 0
}) => {
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
width += ctx.measureText(textStr).width + marginLeft + marginRight;
}
);
return width;
}
/**
* 渲染一段文字
* @param { object } drawData - 绘制数据
* @param { number } drawData.x - x坐标 rpx
* @param { number } drawData.y - y坐标 rpx
* @param { number } drawData.fontSize - 文字大小 rpx
* @param { number } [drawData.color] - 颜色
* @param { string } [drawData.baseLine] - 基线对齐方式 top| middle|bottom|...
* @param { string } [drawData.textAlign='left'] - 对齐方式 left|center|right
* @param { string } drawData.text - 当Object类型时,参数为 text 字段的参数,marginLeft、marginRight这两个字段可用
* @param { number } [drawData.opacity=1] - 1为不透明,0为透明
* @param { string } [drawData.textDecoration='none']
* @param { number } [drawData.width] - 文字宽度 没有指定为画布宽度
* @param { number } [drawData.lineNum=1] - 根据宽度换行,最多的行数
* @param { number } [drawData.lineHeight=0] - 行高
* @param { string } [drawData.fontWeight='normal'] - 'bold' 加粗字体,目前小程序不支持 100 - 900 加粗
* @param { string } [drawData.fontStyle='normal'] - 'italic' 倾斜字体
* @param { string } [drawData.fontFamily="sans-serif"] - 小程序默认字体为 'sans-serif', 请输入小程序支持的字体
*
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export function _drawSingleText(drawData, drawOptions) {
const {
x = 0,
y = 0,
text,
color,
width,
fontSize = 28,
baseLine = 'top',
textAlign = 'left',
opacity = 1,
textDecoration = 'none',
lineNum = 1,
lineHeight = 0,
fontWeight = 'normal',
fontStyle = 'normal',
fontFamily = 'sans-serif'
} = drawData;
const { ctx } = drawOptions;
// 画笔初始化
ctx.save();
ctx.beginPath();
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
ctx.globalAlpha = opacity;
ctx.fillStyle = color;
ctx.textBaseline = baseLine;
ctx.textAlign = textAlign;
let textWidth = ctx.measureText(text).width; // 测量文本宽度
const textArr: string[] = [];
// 文本超出换行
if (textWidth > width) {
// 如果超出一行 ,则判断要分为几行
let fillText = ''; // 当前行已拼接的文字
let line = 1; // 当前是第几行
for (let i = 0; i <= text.length - 1; i++) {
// 将文字转为数组,一行文字一个元素
fillText += text[i]; // 当前已拼接文字串
const nextText = i < text.length - 1 ? fillText + text[i + 1] : fillText; // 再拼接下一个文字
const restWidth = width - ctx.measureText(nextText).width; // 拼接下一个文字后的剩余宽度
if (restWidth < 0) {
// 如果拼接下一个字就超出宽度则添加者省略号或者换行
if (line === lineNum) {
// 已经是最后一行,就拼接省略号
if (
restWidth + ctx.measureText(text[i + 1]).width >
ctx.measureText('...').width
) {
// 剩余宽度能否放下省略号
fillText = `${fillText}...`;
} else {
fillText = `${fillText.substr(0, fillText.length - 1)}...`;
}
textArr.push(fillText);
break;
} else {
// 如果不是最后一行,就换行
textArr.push(fillText);
line++;
fillText = '';
}
} else if (i === text.length - 1) {
textArr.push(fillText);
}
}
textWidth = width;
} else {
textArr.push(text);
}
// 按行渲染文字
textArr.forEach((item, index) =>
ctx.fillText(
item,
getTextX(textAlign, x, width), // 根据文本对齐方式和宽度确定 x 坐标
y + (lineHeight || fontSize) * index // 根据行数、行高 || 字体大小确定 y 坐标
)
);
ctx.restore();
// 文本修饰,下划线、删除线什么的
if (textDecoration !== 'none') {
let lineY = y;
if (textDecoration === 'line-through') {
// 目前只支持贯穿线
lineY = y;
}
ctx.save();
ctx.moveTo(x, lineY);
ctx.lineTo(x + textWidth, lineY);
ctx.strokeStyle = color;
ctx.stroke();
ctx.restore();
}
return textWidth;
}
/**
* 渲染文字
* @param { object } params - 绘制数据
* @param { number } params.x - x坐标 rpx
* @param { number } params.y - y坐标 rpx
* @param { number } params.fontSize - 文字大小 rpx
* @param { number } [params.color] - 颜色
* @param { string } [params.baseLine] - 基线对齐方式 top| middle|bottom
* @param { string } [params.textAlign='left'] - 对齐方式 left|center|right
* @param { string } params.text - 当Object类型时,参数为 text 字段的参数,marginLeft、marginRight这两个字段可用
* @param { number } [params.opacity=1] - 1为不透明,0为透明
* @param { string } [params.textDecoration='none']
* @param { number } [params.width] - 文字宽度 没有指定为画布宽度
* @param { number } [params.lineNum=1] - 根据宽度换行,最多的行数
* @param { number } [params.lineHeight=0] - 行高
* @param { string } [params.fontWeight='normal'] - 'bold' 加粗字体,目前小程序不支持 100 - 900 加粗
* @param { string } [params.fontStyle='normal'] - 'italic' 倾斜字体
* @param { string } [params.fontFamily="sans-serif"] - 小程序默认字体为 'sans-serif', 请输入小程序支持的字体
*
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export function drawText(params, drawOptions) {
const { x = 0, y = 0, text, baseLine } = params;
if (Object.prototype.toString.call(text) === '[object Array]') {
const preText = { x, y, baseLine };
// 遍历多行文字,一行一行渲染
text.forEach((item) => {
preText.x += item.marginLeft || 0;
// TODO:多段文字超出一行的处理
const textWidth = _drawSingleText(
Object.assign(item, { ...preText, y: y + (item.marginTop || 0) }),
drawOptions
);
preText.x += textWidth + (item.marginRight || 0); // 下一段文字的 x 坐标为上一段字 x坐标 + 文字宽度 + marginRight
});
} else {
_drawSingleText(params, drawOptions);
}
}
/**
* @description 渲染线
* @param { number } startX - 起始坐标
* @param { number } startY - 起始坐标
* @param { number } endX - 终结坐标
* @param { number } endY - 终结坐标
* @param { number } width - 线的宽度
* @param { string } [color] - 线的颜色
*
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export function drawLine(drawData, drawOptions) {
const { startX, startY, endX, endY, color, width } = drawData;
const { ctx } = drawOptions;
if (!width) return;
ctx.save();
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
ctx.closePath();
ctx.restore();
}
/**
* 渲染矩形
* @param { number } x - x坐标
* @param { number } y - y坐标
* @param { number } height -高
* @param { string|object } [text] - 块里面可以填充文字,参考texts字段
* @param { number } [width=0] - 宽 如果内部有文字,由文字宽度和内边距决定
* @param { number } [paddingLeft=0] - 内左边距
* @param { number } [paddingRight=0] - 内右边距
* @param { number } [borderWidth] - 边框宽度
* @param { string } [backgroundColor] - 背景颜色
* @param { string } [borderColor] - 边框颜色
* @param { number } [borderRadius=0] - 圆角
* @param { array | null } [borderRadiusGroup= null] - 圆角数组
* @param { number } [opacity=1] - 透明度
*
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export function drawBlock(data, drawOptions) {
const {
x,
y,
text,
width = 0,
height,
opacity = 1,
paddingLeft = 0,
paddingRight = 0,
borderWidth,
backgroundColor,
borderColor,
borderRadius = 0,
borderRadiusGroup = null
} = data || {};
const { ctx } = drawOptions;
ctx.save(); // 先保存画笔样式,等下恢复回来
ctx.globalAlpha = opacity;
let blockWidth = 0; // 块的宽度
let textX = 0;
let textY = 0;
// 渲染块内文字
if (text) {
// 如果文字宽度超出块宽度,则块的宽度为:文字的宽度 + 内边距
const textWidth = _getTextWidth(
typeof text.text === 'string' ? text : text.text,
drawOptions
);
blockWidth = textWidth > width ? textWidth : width;
blockWidth += paddingLeft + paddingLeft;
const { textAlign = 'left' } = text;
textY = y; // 文字默认定位在块的左上角
textX = x + paddingLeft;
// 文字居中
if (textAlign === 'center') {
textX = blockWidth / 2 + x;
} else if (textAlign === 'right') {
textX = x + blockWidth - paddingRight;
}
drawText(Object.assign(text, { x: textX, y: textY }), drawOptions);
} else {
blockWidth = width;
}
// 画矩形背景
if (backgroundColor) {
const grd = getLinearColor(ctx, backgroundColor, x, y, blockWidth, height);
ctx.fillStyle = grd;
// 画圆角矩形
if (borderRadius > 0) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
r: borderRadius
};
_drawRadiusRect(drawData, drawOptions);
ctx.fill(); // 填充路径
} else if (borderRadiusGroup) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
g: borderRadiusGroup
};
_drawRadiusGroupRect(drawData, drawOptions);
ctx.fill(); // 填充路径
} else {
ctx.fillRect(x, y, blockWidth, height); // 绘制矩形
}
}
// 画边框
if (borderWidth && borderRadius > 0) {
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
if (borderRadius > 0) {
// 画圆角矩形边框
const drawData = {
x,
y,
w: blockWidth,
h: height,
r: borderRadius
};
_drawRadiusRect(drawData, drawOptions);
ctx.stroke();
} else {
ctx.strokeRect(x, y, blockWidth, height);
}
}
ctx.restore(); // 将 canvas 恢复到最近的保存状态的方法
}
/**
* @description 渲染图片
* @param { object } data
* @param { number } sx - 源图像的矩形选择框的左上角 x 坐标 裁剪
* @param { number } sy - 源图像的矩形选择框的左上角 y 坐标 裁剪
* @param { number } sw - 源图像的矩形选择框的宽度 裁剪
* @param { number } sh - 源图像的矩形选择框的高度 裁剪
* @param { number } x - 图像的左上角在目标 canvas 上 x 轴的位置 定位
* @param { number } y - 图像的左上角在目标 canvas 上 y 轴的位置 定位
* @param { number } w - 在目标画布上绘制图像的宽度,允许对绘制的图像进行缩放 定位
* @param { number } h - 在目标画布上绘制图像的高度,允许对绘制的图像进行缩放 定位
* @param { number } [borderRadius=0] - 圆角
* @param { array | null } [borderRadiusGroup= null] - 圆角数组
* @param { number } [borderWidth=0] - 边框
*
* @param { object } drawOptions - 绘制对象
* @param { object } drawOptions.ctx - ctx对象
*/
export const drawImage = (data, drawOptions) =>
new Promise<void>((resolve) => {
const { canvas, ctx } = drawOptions;
const {
x,
y,
w,
h,
sx,
sy,
sw,
sh,
imgPath,
borderRadius = 0,
borderWidth = 0,
borderColor,
borderRadiusGroup = null
} = data;
ctx.save();
if (borderRadius > 0) {
_drawRadiusRect(
{
x,
y,
w,
h,
r: borderRadius
},
drawOptions
);
ctx.clip(); // 裁切,后续绘图限制在这个裁切范围内,保证图片圆角
ctx.fill();
const img = canvas.createImage(); // 创建图片对象
img.src = imgPath;
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h);
if (borderWidth > 0) {
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
ctx.stroke();
}
resolve();
ctx.restore();
};
} else if (borderRadiusGroup) {
_drawRadiusGroupRect(
{
x,
y,
w,
h,
g: borderRadiusGroup
},
drawOptions
);
ctx.clip(); // 裁切,后续绘图限制在这个裁切范围内,保证图片圆角
ctx.fill();
const img = canvas.createImage(); // 创建图片对象
img.src = imgPath;
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h);
resolve();
ctx.restore();
};
} else {
const img = canvas.createImage(); // 创建图片对象
img.src = imgPath;
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h);
resolve();
ctx.restore();
};
}
});
/* eslint-disable prefer-destructuring */
import Taro, { CanvasContext, CanvasGradient } from '@tarojs/taro';
declare const wx: any;
/**
* @description 生成随机字符串
* @param { number } length - 字符串长度
* @returns { string }
*/
export function randomString(length) {
let str = Math.random().toString(36).substr(2);
if (str.length >= length) {
return str.substr(0, length);
}
str += randomString(length - str.length);
return str;
}
/**
* 随机创造一个id
* @param { number } length - 字符串长度
* @returns { string }
*/
export function getRandomId(prefix = 'canvas', length = 10) {
return prefix + randomString(length);
}
/**
* @description 获取最大高度
* @param {} config
* @returns { number }
*/
// export function getHeight (config) {
// const getTextHeight = text => {
// const fontHeight = text.lineHeight || text.fontSize
// let height = 0
// if (text.baseLine === 'top') {
// height = fontHeight
// } else if (text.baseLine === 'middle') {
// height = fontHeight / 2
// } else {
// height = 0
// }
// return height
// }
// const heightArr: number[] = [];
// (config.blocks || []).forEach(item => {
// heightArr.push(item.y + item.height)
// });
// (config.texts || []).forEach(item => {
// let height
// if (Object.prototype.toString.call(item.text) === '[object Array]') {
// item.text.forEach(i => {
// height = getTextHeight({ ...i, baseLine: item.baseLine })
// heightArr.push(item.y + height)
// })
// } else {
// height = getTextHeight(item)
// heightArr.push(item.y + height)
// }
// });
// (config.images || []).forEach(item => {
// heightArr.push(item.y + item.height)
// });
// (config.lines || []).forEach(item => {
// heightArr.push(item.startY)
// heightArr.push(item.endY)
// })
// const sortRes = heightArr.sort((a, b) => b - a)
// let canvasHeight = 0
// if (sortRes.length > 0) {
// canvasHeight = sortRes[0]
// }
// if (config.height < canvasHeight || !config.height) {
// return canvasHeight
// }
// return config.height
// }
/**
* 将http转为https
* @param {String}} rawUrl 图片资源url
* @returns { string }
*/
export function mapHttpToHttps(rawUrl) {
if (rawUrl.indexOf(':') < 0 || rawUrl.startsWith('http://tmp')) {
return rawUrl;
}
const urlComponent = rawUrl.split(':');
if (urlComponent.length === 2) {
if (urlComponent[0] === 'http') {
urlComponent[0] = 'https';
return `${urlComponent[0]}:${urlComponent[1]}`;
}
}
return rawUrl;
}
/**
* 获取 rpx => px 的转换系数
* @returns { number } factor 单位转换系数 1rpx = factor * px
*/
export const getFactor = () => {
const sysInfo = Taro.getSystemInfoSync();
const { screenWidth } = sysInfo;
return screenWidth / 750;
};
/**
* rpx => px 单位转换
* @param { number } rpx - 需要转换的数值
* @param { number } factor - 转化因子
* @returns { number }
*/
export const toPx = (rpx, factor = getFactor()) =>
parseInt(String(rpx * factor), 10);
/**
* px => rpx 单位转换
* @param { number } px - 需要转换的数值
* @param { number } factor - 转化因子
* @returns { number }
*/
export const toRpx = (px, factor = getFactor()) =>
parseInt(String(px / factor), 10);
/**
* 下载图片资源
* @param { string } url
* @returns { Promise }
*/
export function downImage(url) {
return new Promise<string>((resolve, reject) => {
// eslint-disable-next-line no-undef
if (/^http/.test(url) && !new RegExp(wx.env.USER_DATA_PATH).test(url)) {
// wx.env.USER_DATA_PATH 文件系统中的用户目录路径
Taro.downloadFile({
url: mapHttpToHttps(url),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.tempFilePath);
} else {
console.log('下载失败', res);
reject(res);
}
},
fail(err) {
console.log('下载失败了', err);
reject(err);
}
});
} else {
resolve(url); // 支持本地地址
}
});
}
/**
* 下载图片并获取图片信息
* @param {} item 图片参数信息
* @param {} index 图片下标
* @returns { Promise } result 整理后的图片信息
*/
export const getImageInfo = (item, index) =>
new Promise((resolve, reject) => {
const { x, y, width, height, url, zIndex } = item;
downImage(url).then((imgPath) =>
Taro.getImageInfo({ src: imgPath })
.then((imgInfo) => {
// 获取图片信息
// 根据画布的宽高计算出图片绘制的大小,这里会保证图片绘制不变形, 即宽高比不变,截取再拉伸
let sx; // 截图的起点 x 坐标
let sy; // 截图的起点 y 坐标
const borderRadius = item.borderRadius || 0;
const imgWidth = toRpx(imgInfo.width); // 图片真实宽度 单位 px
const imgHeight = toRpx(imgInfo.height); // 图片真实高度 单位 px
// 根据宽高比截取图片
if (imgWidth / imgHeight <= width / height) {
sx = 0;
sy = (imgHeight - (imgWidth / width) * height) / 2;
} else {
sy = 0;
sx = (imgWidth - (imgHeight / height) * width) / 2;
}
// 给 canvas 画图准备参数,详见 ./draw.ts-drawImage
const result = {
type: 'image',
borderRadius,
borderWidth: item.borderWidth,
borderColor: item.borderColor,
borderRadiusGroup: item.borderRadiusGroup,
zIndex: typeof zIndex !== 'undefined' ? zIndex : index,
imgPath: url,
sx,
sy,
sw: imgWidth - sx * 2,
sh: imgHeight - sy * 2,
x,
y,
w: width,
h: height
};
resolve(result);
})
.catch((err) => {
console.log('读取图片信息失败', err);
reject(err);
})
);
});
/**
* 获取线性渐变色
* @param {CanvasContext} ctx canvas 实例对象
* @param {String} color 线性渐变色,如 'linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #fff 100%)'
* @param {Number} startX 起点 x 坐标
* @param {Number} startY 起点 y 坐标
* @param {Number} w 宽度
* @param {Number} h 高度
* @returns {}
*/
// TODO: 待优化, 支持所有角度,多个颜色的线性渐变
export function getLinearColor(
ctx: CanvasContext,
color,
startX,
startY,
w,
h
) {
if (
typeof startX !== 'number' ||
typeof startY !== 'number' ||
typeof w !== 'number' ||
typeof h !== 'number'
) {
console.warn('坐标或者宽高只支持数字');
return color;
}
let grd: CanvasGradient | string = color;
if (color.includes('linear-gradient')) {
// fillStyle 不支持线性渐变色
const colorList = color.match(/\((\d+)deg,\s(.+)\s\d+%,\s(.+)\s\d+%/);
const radian = colorList[1]; // 渐变弧度(角度)
const color1 = colorList[2];
const color2 = colorList[3];
const L = Math.sqrt(w * w + h * h);
const x = Math.ceil(Math.sin(180 - radian) * L);
const y = Math.ceil(Math.cos(180 - radian) * L);
// 根据弧度和宽高确定渐变色的两个点的坐标
if (Number(radian) === 180 || Number(radian) === 0) {
if (Number(radian) === 180) {
grd = ctx.createLinearGradient(startX, startY, startX, startY + h);
}
if (Number(radian) === 0) {
grd = ctx.createLinearGradient(startX, startY + h, startX, startY);
}
} else if (radian > 0 && radian < 180) {
grd = ctx.createLinearGradient(startX, startY, x + startX, y + startY);
} else {
throw new Error('只支持0 <= 颜色弧度 <= 180');
}
(grd as CanvasGradient).addColorStop(0, color1);
(grd as CanvasGradient).addColorStop(1, color2);
}
return grd;
}
/**
* 根据文字对齐方式设置坐标
* @param {*} imgPath
* @param {*} index
* @returns { Promise }
*/
export function getTextX(textAlign, x, width) {
let newX = x;
if (textAlign === 'center') {
newX = width / 2 + x;
} else if (textAlign === 'right') {
newX = width + x;
}
return newX;
}
......@@ -57,3 +57,16 @@
}
}
}
.wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.block {
width: 120px;
height: 120px;
background-color: #fff;
}
......
<!--
* @Date: 2022-09-26 14:36:57
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-09-26 17:37:54
* @LastEditTime: 2022-09-27 16:38:57
* @FilePath: /swx/src/pages/activityDetail/index.vue
* @Description: 文件描述
-->
......@@ -122,18 +122,41 @@
<activity-bar />
<van-action-sheet
:z-index="10"
:show="show_share"
:actions="actions_share"
cancel-text="取消"
:overlay="true"
@cancel="onCancelShare"
@select="onSelectShare"
/>
<van-overlay :show="show_post" :z-index="1">
<view class="wrapper">
<view class="preview-area" @click="onClickPost">
<image v-if="posterPath" :src="posterPath" mode="widthFix" />
</view>
</view>
</van-overlay>
<PosterBuilder v-if="startDraw" custom-style="position: fixed; left: 200%;" :config="base" @success="drawSuccess"
@fail="drawFail" />
<van-action-sheet
:z-index="1"
:show="show_save"
:actions="actions_save"
cancel-text="取消"
:overlay="false"
@cancel="onCancelSave"
@select="onSelectSave"
/>
</template>
<script setup>
import img_demo from '@/images/demo@2x.png'
import img_demo1 from '@/images/demo@2x-1.png'
import activityBar from '@/components/activity-bar.vue'
import Taro from '@tarojs/taro'
import PosterBuilder from '@/components/PosterBuilder/index.vue';
import { ref } from "vue";
......@@ -149,10 +172,293 @@ const shareActivity = () => {
const onCancelShare = () => {
show_share.value = false;
}
const onSelectShare = (event) => {
const onSelectShare = ({ detail }) => {
// TODO: 需要完善 分享朋友和生成海报功能
console.warn(event.detail);
show_share.value = false;
if (detail.name === '生成海报') {
show_post.value = true;
start()
}
}
const show_post = ref(false)
const onClickPost = () => {
show_save.value = true;
}
// 生成海报
const startDraw = ref(false)
const posterPath = ref('')
const base = {
width: 1024,
height: 1334,
backgroundColor: '',
debug: false,
blocks: [
{
x: 40,
y: 20,
width: 950,
height: 1200,
paddingLeft: 0,
paddingRight: 0,
borderWidth: 1,
borderColor: '#D9DCD5',
backgroundColor: '#fff',
borderRadius: 16,
// borderRadiusGroup: [0, 0, 18, 18],
},
{
x: 40,
y: 730,
// width: 580,
height: 75,
paddingLeft: 80,
paddingRight: 0,
borderWidth: 0,
text: {
x: 0,
y: 0,
text: '2022-08-25 14:00',
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'top',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
// width: 580,
zIndex: 0,
},
backgroundColor: '#FFF9F3',
// borderColor: 'red',
// backgroundColor: '#EFF3F5',
// borderRadius: 0,
borderRadiusGroup: [0, 25, 25, 0],
// borderRadius: 16,
// zIndex: 100,
},
{
x: 40,
y: 830,
// width: 580,
height: 75,
paddingLeft: 80,
paddingRight: 0,
borderWidth: 0,
text: {
x: 0,
y: 0,
text: '上海市杨浦区军工路100号A座05室',
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'top',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
// width: 580,
zIndex: 0,
},
backgroundColor: '#FFF9F3',
// borderColor: 'red',
// backgroundColor: '#EFF3F5',
// borderRadius: 0,
borderRadiusGroup: [0, 25, 25, 0],
// borderRadius: 16,
// zIndex: 100,
},
],
texts: [
{
x: 80,
y: 630,
text: '八段锦-智慧没有烦恼',
fontSize: 50,
color: '#000',
opacity: 1,
baseLine: 'middle',
lineHeight: 60,
lineNum: 2,
textAlign: 'left',
width: 800,
zIndex: 999,
// fontWeight: 'bold',
fontFamily: 'Monospace',
},
{
x: 135,
y: 770,
text: '2022-08-25 14:00',
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'middle',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
// width: 580,
zIndex: 999,
},
{
x: 135,
y: 870,
text: '上海市杨浦区军工路100号A座05室',
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'middle',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
// width: 580,
zIndex: 999,
},
{
x: 300,
y: 1080,
text: '妙净',
fontSize: 50,
color: '#333',
opacity: 1,
baseLine: 'middle',
textAlign: 'left',
lineHeight: 50,
lineNum: 1,
zIndex: 999,
},
{
x: 300,
y: 1150,
text: '邀请你一起来活动!',
fontSize: 42,
color: '#8F9399',
opacity: 1,
baseLine: 'middle',
textAlign: 'left',
lineHeight: 42,
lineNum: 1,
zIndex: 999,
}
],
images: [
{
url: 'https://tva1.sinaimg.cn/large/5f01a858gy1h6l450x64zj20ku0bq78c.jpg',
width: 950,
height: 500,
x: 40,
y: 20,
// borderRadius: 16,
borderRadiusGroup: [18, 18, 0, 0],
zIndex: 10,
// borderRadius: 150,
// borderWidth: 10,
// borderColor: 'red',
},
{
url: 'https://tva1.sinaimg.cn/large/5f01a858gy1h6l5d2bmijj200s00s0sh.jpg',
width: 40,
height: 40,
x: 80,
y: 750,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
{
url: 'https://tva1.sinaimg.cn/large/5f01a858gy1h6l5dc9q31j200o00u0ol.jpg',
width: 35,
height: 40,
x: 80,
y: 850,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
{
url: 'https://pic.juncao.cc/cms/images/minapp.jpg',
width: 170,
height: 170,
x: 80,
y: 1030,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
{
url: 'https://pic.juncao.cc/cms/images/minapp.jpg',
width: 170,
height: 170,
x: 750,
y: 1030,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
],
lines: [
{
startY: 970,
startX: 80,
endX: 950,
endY: 971,
width: 1,
color: '#8F9399',
}
]
}
const start = () => {
startDraw.value = true;
if (!posterPath.value) Taro.showLoading();
}
const drawSuccess = (result) => {
console.warn('绘制好了', result);
const { tempFilePath, errMsg } = result;
if (errMsg === 'canvasToTempFilePath:ok') {
posterPath.value = tempFilePath;
Taro.hideLoading();
} else {
Taro.hideLoading();
Taro.showToast({
title: '失败,请稍后重试',
icon: 'none',
duration: 2500
});
}
};
const drawFail = (result) => {
console.warn('绘制失败', result);
Taro.hideLoading();
}
const savePoster = () => {
Taro.saveImageToPhotosAlbum({
filePath: posterPath.value,
success() {
Taro.showToast({
title: '已保存到相册',
icon: 'success',
duration: 2000
});
// posterPath.value = '';
}
});
}
const show_save = ref(false);
const actions_save = ref([{
name: '保存至相册'
}]);
const onCancelSave = () => {
show_save.value = false;
show_post.value = false;
// posterPath.value = '';
}
const onSelectSave = ({ detail }) => {
if (detail.name === '保存至相册') {
show_save.value = false;
show_post.value = false;
savePoster()
}
}
</script>
......
export default {
navigationBarTitleText: 'demo',
usingComponents: {
},
}
.red {
color: red;
}
<template>
<view class="index">
<view class="action-bar">
<button @tap="start">生成海报</button>
<button @tap="savePoster">下载海报</button>
</view>
<view class="preview-area">
<image v-if="posterPath" :src="posterPath" mode="widthFix" />
<view v-else class="text">预览区域</view>
</view>
<PosterBuilder v-if="startDraw" custom-style="position: fixed; left: 200%;" :config="base" @success="drawSuccess"
@fail="drawFail" />
</view>
</template>
<script>
import Taro from '@tarojs/taro'
import { defineComponent, reactive, toRefs } from 'vue';
import PosterBuilder from '@/components/PosterBuilder/index.vue';
export default defineComponent({
name: 'Index',
components: {
PosterBuilder,
},
setup() {
const state = reactive({
startDraw: false,
posterPath: ''
})
const base = {
width: 750,
height: 1334,
backgroundColor: '#232422',
debug: false,
blocks: [
// 头部底色
{
x: 32,
y: 80,
width: 686,
height: 160,
paddingLeft: 0,
paddingRight: 0,
backgroundColor: '#FFFFFF',
borderRadius: 32,
zIndex: 10
},
//底部背景
{
x: 32,
y: 950,
width: 686,
height: 302,
paddingLeft: 0,
paddingRight: 0,
borderRadiusGroup: [0, 0, 16, 16],
backgroundColor: '#FFFFFF',
zIndex: 11
}
],
texts: [
{
x: 216,
y: 108,
text: 'BiBin',
width: 380,
lineNum: 2, // 最多几行
fontSize: 36,
fontWeight: 'bold',
color: '#1A171B',
zIndex: 11
},
{
x: 216,
y: 174,
text: '为你挑选了一个好物',
width: 380,
fontSize: 28,
color: '#7C7D7A',
zIndex: 11
},
{
x: 64,
y: 994,
text: `¥6799`,
fontSize: 48,
color: '#ED2D2B',
fontWeight: 'bold',
zIndex: 12
},
{
x: 64,
y: 1092,
text: 'Apple iPhone 13 (A2634) 256GB 蓝色 支持移动联通电信5G 双卡双待手机',
fontSize: 32,
width: 380,
color: '#282925',
lineNum: 2, // 最多几行
zIndex: 12
}
],
images: [
{
x: 64,
y: 100,
width: 120,
height: 120,
borderRadius: 60,
url: 'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTJP05RJ5icJkUkBjVtSb3DHib4pGRVEqjw3qNic53kd1tJmibPpzR1etJnWJFiaIJDLK6TDzD3d5SyPsXQ/132',
zIndex: 11
},
{
x: 32,
y: 272,
width: 686,
height: 686,
url: 'https://m.360buyimg.com/mobilecms/s750x750_jfs/t1/119360/32/20820/239818/618be3c6E1dbf188d/4070880e024273bb.jpg!q80.dpg',
borderRadiusGroup: [16, 16, 0, 0],
zIndex: 11
}
]
};
const start = () => {
state.startDraw = true;
Taro.showLoading();
}
const drawSuccess = (result) => {
console.warn('绘制好了', result);
const { tempFilePath, errMsg } = result;
if (errMsg === 'canvasToTempFilePath:ok') {
state.posterPath = tempFilePath;
Taro.hideLoading();
} else {
Taro.hideLoading();
Taro.showToast({
title: '失败,请稍后重试',
icon: 'none',
duration: 2500
});
}
};
const drawFail = (result) => {
console.warn('绘制失败', result);
Taro.hideLoading();
}
const savePoster = () => {
Taro.saveImageToPhotosAlbum({
filePath: state.posterPath,
success() {
Taro.showToast({
title: '已保存到相册',
icon: 'success',
duration: 2000
});
}
});
}
return {
...toRefs(state),
base,
start,
drawSuccess,
drawFail,
savePoster
}
}
});
</script>
<style>
.index {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
.action-bar {
display: flex;
}
.preview-area {
width: 80%;
min-height: 800px;
margin: 20px auto;
text-align: center;
border: 1px solid #cccccc;
}
.text {
line-height: 800px;
}
</style>