| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813 |
- import { getDevicePixelRatio, isEmpty } from "@/.cool";
- import { getCurrentInstance } from "vue";
- import type {
- CropImageResult,
- DivRenderOptions,
- ImageRenderOptions,
- TextRenderOptions,
- TransformOptions
- } from "../types";
- /**
- * Canvas 绘图类,封装了常用的绘图操作
- */
- export class Canvas {
- // uni-app CanvasContext 对象
- context: CanvasContext | null = null;
- // 2D 渲染上下文
- ctx: CanvasRenderingContext2D | null = null;
- // 组件作用域(用于小程序等环境)
- scope: ComponentPublicInstance | null = null;
- // 画布ID
- canvasId: string | null = null;
- // 渲染队列,存储所有待渲染的异步操作
- renderQuene: (() => Promise<void>)[] = [];
- // 图片渲染队列,存储所有待处理的图片参数
- imageQueue: ImageRenderOptions[] = [];
- /**
- * 构造函数
- * @param canvasId 画布ID
- */
- constructor(canvasId: string) {
- const { proxy } = getCurrentInstance()!;
- // 当前页面作用域
- this.scope = proxy;
- // 画布ID
- this.canvasId = canvasId;
- }
- /**
- * 创建画布上下文
- * @returns Promise<void>
- */
- async create(): Promise<void> {
- const dpr = getDevicePixelRatio(); // 获取设备像素比
- return new Promise((resolve) => {
- uni.createCanvasContextAsync({
- id: this.canvasId!,
- component: this.scope,
- success: (context: CanvasContext) => {
- this.context = context;
- this.ctx = context.getContext("2d")!;
- this.ctx.scale(dpr, dpr); // 按照 dpr 缩放,保证高清
- resolve();
- }
- });
- });
- }
- /**
- * 设置画布高度
- * @param value 高度
- * @returns Canvas
- */
- height(value: number): Canvas {
- this.ctx!.canvas.height = value;
- return this;
- }
- /**
- * 设置画布宽度
- * @param value 宽度
- * @returns Canvas
- */
- width(value: number): Canvas {
- this.ctx!.canvas.width = value;
- return this;
- }
- /**
- * 添加块(矩形/圆角矩形)渲染到队列
- * @param options DivRenderOptions
- * @returns Canvas
- */
- div(options: DivRenderOptions): Canvas {
- const render = async () => {
- this.divRender(options);
- };
- this.renderQuene.push(render);
- return this;
- }
- /**
- * 添加文本渲染到队列
- * @param options TextRenderOptions
- * @returns Canvas
- */
- text(options: TextRenderOptions): Canvas {
- const render = async () => {
- this.textRender(options);
- };
- this.renderQuene.push(render);
- return this;
- }
- /**
- * 添加图片渲染到队列
- * @param options ImageRenderOptions
- * @returns Canvas
- */
- image(options: ImageRenderOptions): Canvas {
- const render = async () => {
- await this.imageRender(options);
- };
- this.imageQueue.push(options);
- this.renderQuene.push(render);
- return this;
- }
- /**
- * 执行绘制流程(预加载图片后依次渲染队列)
- */
- async draw(): Promise<void> {
- // 如果有图片,先预加载
- if (!isEmpty(this.imageQueue)) {
- await this.preloadImage();
- }
- this.render();
- }
- /**
- * 下载图片(获取本地路径,兼容APP等平台)
- * @param item ImageRenderOptions
- * @returns Promise<void>
- */
- downloadImage(item: ImageRenderOptions): Promise<void> {
- return new Promise((resolve, reject) => {
- uni.getImageInfo({
- src: item.url,
- success: (res) => {
- // #ifdef APP
- item.url = res.path; // APP端需用本地路径
- // #endif
- resolve();
- },
- fail: (err) => {
- console.error(err);
- reject(err);
- }
- });
- });
- }
- /**
- * 预加载所有图片,确保图片可用
- */
- async preloadImage(): Promise<void> {
- await Promise.all(
- this.imageQueue.map((e) => {
- return this.downloadImage(e);
- })
- );
- }
- /**
- * 设置背景颜色
- * @param color 颜色字符串
- */
- private setBackground(color: string) {
- this.ctx!.fillStyle = color;
- }
- /**
- * 设置边框(支持圆角)
- * @param options DivRenderOptions
- */
- private setBorder(options: DivRenderOptions) {
- const { x, y, width: w = 0, height: h = 0, borderWidth, borderColor, radius: r } = options;
- if (borderWidth == null || borderColor == null) return;
- this.ctx!.lineWidth = borderWidth;
- this.ctx!.strokeStyle = borderColor;
- // 偏移距离,保证边框居中
- let p = borderWidth / 2;
- // 是否有圆角
- if (r != null) {
- this.drawRadius(x - p, y - p, w + 2 * p, h + 2 * p, r + p);
- this.ctx!.stroke();
- } else {
- this.ctx!.strokeRect(x - p, y - p, w + 2 * p, h + 2 * p);
- }
- }
- /**
- * 设置变换(缩放、旋转、平移)
- * @param options TransformOptions
- */
- private setTransform(options: TransformOptions) {
- const ctx = this.ctx!;
- // 平移
- if (options.translateX != null || options.translateY != null) {
- ctx.translate(options.translateX ?? 0, options.translateY ?? 0);
- }
- // 旋转(角度转弧度)
- if (options.rotate != null) {
- ctx.rotate((options.rotate * Math.PI) / 180);
- }
- // 缩放
- if (options.scale != null) {
- // 统一缩放
- ctx.scale(options.scale, options.scale);
- } else if (options.scaleX != null || options.scaleY != null) {
- // 分别缩放
- ctx.scale(options.scaleX ?? 1, options.scaleY ?? 1);
- }
- }
- /**
- * 绘制带圆角的路径
- * @param x 左上角x
- * @param y 左上角y
- * @param w 宽度
- * @param h 高度
- * @param r 圆角半径
- */
- private drawRadius(x: number, y: number, w: number, h: number, r: number) {
- // 圆角半径不能超过宽高一半
- const maxRadius = Math.min(w / 2, h / 2);
- const radius = Math.min(r, maxRadius);
- this.ctx!.beginPath();
- // 从左上角圆弧的结束点开始
- this.ctx!.moveTo(x + radius, y);
- // 顶边
- this.ctx!.lineTo(x + w - radius, y);
- // 右上角圆弧
- this.ctx!.arc(x + w - radius, y + radius, radius, -Math.PI / 2, 0);
- // 右边
- this.ctx!.lineTo(x + w, y + h - radius);
- // 右下角圆弧
- this.ctx!.arc(x + w - radius, y + h - radius, radius, 0, Math.PI / 2);
- // 底边
- this.ctx!.lineTo(x + radius, y + h);
- // 左下角圆弧
- this.ctx!.arc(x + radius, y + h - radius, radius, Math.PI / 2, Math.PI);
- // 左边
- this.ctx!.lineTo(x, y + radius);
- // 左上角圆弧
- this.ctx!.arc(x + radius, y + radius, radius, Math.PI, -Math.PI / 2);
- this.ctx!.closePath();
- }
- /**
- * 裁剪图片,支持多种裁剪模式
- * @param mode 裁剪模式
- * @param canvasWidth 目标区域宽度
- * @param canvasHeight 目标区域高度
- * @param imageWidth 原图宽度
- * @param imageHeight 原图高度
- * @param drawX 绘制起点X
- * @param drawY 绘制起点Y
- * @returns CropImageResult
- */
- private cropImage(
- mode:
- | "scaleToFill"
- | "aspectFit"
- | "aspectFill"
- | "center"
- | "top"
- | "bottom"
- | "left"
- | "right"
- | "topLeft"
- | "topRight"
- | "bottomLeft"
- | "bottomRight",
- canvasWidth: number,
- canvasHeight: number,
- imageWidth: number,
- imageHeight: number,
- drawX: number,
- drawY: number
- ): CropImageResult {
- // sx, sy, sw, sh: 原图裁剪区域
- // dx, dy, dw, dh: 画布绘制区域
- let sx = 0,
- sy = 0,
- sw = imageWidth,
- sh = imageHeight;
- let dx = drawX,
- dy = drawY,
- dw = canvasWidth,
- dh = canvasHeight;
- // 计算宽高比
- const imageRatio = imageWidth / imageHeight;
- const canvasRatio = canvasWidth / canvasHeight;
- switch (mode) {
- case "scaleToFill":
- // 拉伸填充整个区域,可能变形
- break;
- case "aspectFit":
- // 保持比例完整显示,可能有留白
- if (imageRatio > canvasRatio) {
- // 图片更宽,以宽度为准
- dw = canvasWidth;
- dh = canvasWidth / imageRatio;
- dx = drawX;
- dy = drawY + (canvasHeight - dh) / 2;
- } else {
- // 图片更高,以高度为准
- dw = canvasHeight * imageRatio;
- dh = canvasHeight;
- dx = drawX + (canvasWidth - dw) / 2;
- dy = drawY;
- }
- break;
- case "aspectFill":
- // 保持比例填充,可能裁剪
- if (imageRatio > canvasRatio) {
- // 图片更宽,裁剪左右
- const scaledWidth = imageHeight * canvasRatio;
- sx = (imageWidth - scaledWidth) / 2;
- sw = scaledWidth;
- } else {
- // 图片更高,裁剪上下
- const scaledHeight = imageWidth / canvasRatio;
- sy = (imageHeight - scaledHeight) / 2;
- sh = scaledHeight;
- }
- break;
- case "center":
- // 居中显示,不缩放,超出裁剪
- sx = Math.max(0, (imageWidth - canvasWidth) / 2);
- sy = Math.max(0, (imageHeight - canvasHeight) / 2);
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX + Math.max(0, (canvasWidth - imageWidth) / 2);
- dy = drawY + Math.max(0, (canvasHeight - imageHeight) / 2);
- dw = sw;
- dh = sh;
- break;
- case "top":
- // 顶部对齐,水平居中
- sx = Math.max(0, (imageWidth - canvasWidth) / 2);
- sy = 0;
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX + Math.max(0, (canvasWidth - imageWidth) / 2);
- dy = drawY;
- dw = sw;
- dh = sh;
- break;
- case "bottom":
- // 底部对齐,水平居中
- sx = Math.max(0, (imageWidth - canvasWidth) / 2);
- sy = Math.max(0, imageHeight - canvasHeight);
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX + Math.max(0, (canvasWidth - imageWidth) / 2);
- dy = drawY + Math.max(0, canvasHeight - imageHeight);
- dw = sw;
- dh = sh;
- break;
- case "left":
- // 左对齐,垂直居中
- sx = 0;
- sy = Math.max(0, (imageHeight - canvasHeight) / 2);
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX;
- dy = drawY + Math.max(0, (canvasHeight - imageHeight) / 2);
- dw = sw;
- dh = sh;
- break;
- case "right":
- // 右对齐,垂直居中
- sx = Math.max(0, imageWidth - canvasWidth);
- sy = Math.max(0, (imageHeight - canvasHeight) / 2);
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX + Math.max(0, canvasWidth - imageWidth);
- dy = drawY + Math.max(0, (canvasHeight - imageHeight) / 2);
- dw = sw;
- dh = sh;
- break;
- case "topLeft":
- // 左上角对齐
- sx = 0;
- sy = 0;
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX;
- dy = drawY;
- dw = sw;
- dh = sh;
- break;
- case "topRight":
- // 右上角对齐
- sx = Math.max(0, imageWidth - canvasWidth);
- sy = 0;
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX + Math.max(0, canvasWidth - imageWidth);
- dy = drawY;
- dw = sw;
- dh = sh;
- break;
- case "bottomLeft":
- // 左下角对齐
- sx = 0;
- sy = Math.max(0, imageHeight - canvasHeight);
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX;
- dy = drawY + Math.max(0, canvasHeight - imageHeight);
- dw = sw;
- dh = sh;
- break;
- case "bottomRight":
- // 右下角对齐
- sx = Math.max(0, imageWidth - canvasWidth);
- sy = Math.max(0, imageHeight - canvasHeight);
- sw = Math.min(imageWidth, canvasWidth);
- sh = Math.min(imageHeight, canvasHeight);
- dx = drawX + Math.max(0, canvasWidth - imageWidth);
- dy = drawY + Math.max(0, canvasHeight - imageHeight);
- dw = sw;
- dh = sh;
- break;
- }
- return {
- // 源图片裁剪区域
- sx,
- sy,
- sw,
- sh,
- // 目标绘制区域
- dx,
- dy,
- dw,
- dh
- } as CropImageResult;
- }
- /**
- * 获取文本每行内容(自动换行、支持省略号)
- * @param options TextRenderOptions
- * @returns string[] 每行内容
- */
- private getTextRows({
- content,
- fontSize = 14,
- width = 100,
- lineClamp = 1,
- overflow,
- letterSpace = 0,
- fontFamily = "sans-serif",
- fontWeight = "normal"
- }: TextRenderOptions) {
- // 临时设置字体以便准确测量
- this.ctx!.save();
- this.ctx!.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
- let arr: string[] = [""];
- let currentLineWidth = 0;
- for (let i = 0; i < content.length; i++) {
- const char = content.charAt(i);
- const charWidth = this.ctx!.measureText(char).width;
- // 计算当前字符加上字符间距后的总宽度
- const needSpace = arr[arr.length - 1].length > 0 && letterSpace > 0;
- const totalWidth = charWidth + (needSpace ? letterSpace : 0);
- if (currentLineWidth + totalWidth > width) {
- // 换行:新行的第一个字符不需要字符间距
- currentLineWidth = charWidth;
- arr.push(char);
- } else {
- // 最后一行且设置超出省略号
- if (overflow == "ellipsis" && arr.length == lineClamp) {
- const ellipsisWidth = this.ctx!.measureText("...").width;
- const ellipsisSpaceWidth = needSpace ? letterSpace : 0;
- if (
- currentLineWidth + totalWidth + ellipsisSpaceWidth + ellipsisWidth >
- width
- ) {
- arr[arr.length - 1] += "...";
- break;
- }
- }
- currentLineWidth += totalWidth;
- arr[arr.length - 1] += char;
- }
- }
- this.ctx!.restore();
- return arr;
- }
- /**
- * 渲染块(矩形/圆角矩形)
- * @param options DivRenderOptions
- */
- private divRender(options: DivRenderOptions) {
- const {
- x,
- y,
- width = 0,
- height = 0,
- radius,
- backgroundColor = "#fff",
- opacity = 1,
- scale,
- scaleX,
- scaleY,
- rotate,
- translateX,
- translateY
- } = options;
- this.ctx!.save();
- // 设置透明度
- this.ctx!.globalAlpha = opacity;
- // 设置背景色
- this.setBackground(backgroundColor);
- // 设置边框
- this.setBorder(options);
- // 设置变换
- this.setTransform({
- scale,
- scaleX,
- scaleY,
- rotate,
- translateX,
- translateY
- });
- // 判断是否有圆角
- if (radius != null) {
- // 绘制圆角路径
- this.drawRadius(x, y, width, height, radius);
- // 填充
- this.ctx!.fill();
- } else {
- // 普通矩形
- this.ctx!.fillRect(x, y, width, height);
- }
- this.ctx!.restore();
- }
- /**
- * 渲染文本
- * @param options TextRenderOptions
- */
- private textRender(options: TextRenderOptions) {
- let {
- fontSize = 14,
- textAlign,
- width,
- color = "#000000",
- x,
- y,
- letterSpace,
- lineHeight,
- fontFamily = "sans-serif",
- fontWeight = "normal",
- opacity = 1,
- scale,
- scaleX,
- scaleY,
- rotate,
- translateX,
- translateY
- } = options;
- // 如果行高为空,则设置为字体大小的1.4倍
- if (lineHeight == null) {
- lineHeight = fontSize * 1.4;
- }
- this.ctx!.save();
- // 应用变换
- this.setTransform({
- scale,
- scaleX,
- scaleY,
- rotate,
- translateX,
- translateY
- });
- // 设置字体样式
- this.ctx!.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
- // 设置透明度
- this.ctx!.globalAlpha = opacity;
- // 设置字体颜色
- this.ctx!.fillStyle = color;
- // 获取每行文本内容
- const rows = this.getTextRows(options);
- // 左偏移量
- let offsetLeft = 0;
- // 字体对齐(无字符间距时使用Canvas的textAlign)
- if (textAlign != null && width != null && (letterSpace == null || letterSpace <= 0)) {
- this.ctx!.textAlign = textAlign;
- switch (textAlign) {
- case "left":
- break;
- case "center":
- offsetLeft = width / 2;
- break;
- case "right":
- offsetLeft = width;
- break;
- }
- } else {
- // 有字符间距时,使用左对齐,手动控制位置
- this.ctx!.textAlign = "left";
- }
- // 计算行间距
- const lineGap = lineHeight - fontSize;
- // 逐行渲染
- for (let i = 0; i < rows.length; i++) {
- const currentRow = rows[i];
- const yPos = (i + 1) * fontSize + y + lineGap * i;
- if (letterSpace != null && letterSpace > 0) {
- // 逐字符计算宽度,确保字符间距准确
- let lineWidth = 0;
- for (let j = 0; j < currentRow.length; j++) {
- lineWidth += this.ctx!.measureText(currentRow.charAt(j)).width;
- if (j < currentRow.length - 1) {
- lineWidth += letterSpace;
- }
- }
- // 计算起始位置(考虑 textAlign)
- let startX = x;
- if (textAlign == "center" && width != null) {
- startX = x + (width - lineWidth) / 2;
- } else if (textAlign == "right" && width != null) {
- startX = x + width - lineWidth;
- }
- // 逐字符渲染
- let charX = startX;
- for (let j = 0; j < currentRow.length; j++) {
- const char = currentRow.charAt(j);
- this.ctx!.fillText(char, charX, yPos);
- // 移动到下一个字符位置
- charX += this.ctx!.measureText(char).width;
- if (j < currentRow.length - 1) {
- charX += letterSpace;
- }
- }
- } else {
- // 普通渲染(无字符间距)
- this.ctx!.fillText(currentRow, x + offsetLeft, yPos);
- }
- }
- this.ctx!.restore();
- }
- /**
- * 渲染图片
- * @param options ImageRenderOptions
- */
- private async imageRender(options: ImageRenderOptions): Promise<void> {
- return new Promise((resolve) => {
- this.ctx!.save();
- // 设置透明度
- this.ctx!.globalAlpha = options.opacity ?? 1;
- // 应用变换
- this.setTransform({
- scale: options.scale,
- scaleX: options.scaleX,
- scaleY: options.scaleY,
- rotate: options.rotate,
- translateX: options.translateX,
- translateY: options.translateY
- });
- // 如果有圆角,先绘制路径并裁剪
- if (options.radius != null) {
- this.drawRadius(
- options.x,
- options.y,
- options.width,
- options.height,
- options.radius
- );
- this.ctx!.clip();
- }
- const temp = this.imageQueue[0];
- let img: Image;
- // 微信小程序/鸿蒙环境创建图片
- // #ifdef MP-WEIXIN || APP-HARMONY
- img = this.context!.createImage();
- // #endif
- // 其他环境创建图片
- // #ifndef MP-WEIXIN || APP-HARMONY
- img = new Image();
- // #endif
- img.src = temp.url;
- img.onload = () => {
- if (options.mode != null) {
- let h: number;
- let w: number;
- // #ifdef H5
- h = img["height"];
- w = img["width"];
- // #endif
- // #ifndef H5
- h = img.height;
- w = img.width;
- // #endif
- // 按模式裁剪并绘制
- const { sx, sy, sw, sh, dx, dy, dw, dh } = this.cropImage(
- options.mode,
- temp.width, // 目标绘制区域宽度
- temp.height, // 目标绘制区域高度
- w, // 原图片宽度
- h, // 原图片高度
- temp.x, // 绘制X坐标
- temp.y // 绘制Y坐标
- );
- // 使用 drawImage 的完整参数形式进行精确裁剪和绘制
- this.ctx!.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
- } else {
- // 不指定模式时,直接绘制整个图片
- this.ctx!.drawImage(img, temp.x, temp.y, temp.width, temp.height);
- }
- this.ctx!.restore();
- this.imageQueue.shift(); // 移除已渲染图片
- resolve();
- };
- });
- }
- /**
- * 依次执行渲染队列中的所有操作
- */
- async render() {
- for (let i = 0; i < this.renderQuene.length; i++) {
- const r = this.renderQuene[i];
- await r();
- }
- }
- }
- /**
- * useCanvas 钩子函数,返回 Canvas 实例
- * @param canvasId 画布ID
- * @returns Canvas
- */
- export const useCanvas = (canvasId: string) => {
- return new Canvas(canvasId);
- };
|