添加 cl-canvas 组件

This commit is contained in:
icssoa
2025-08-19 18:10:49 +08:00
parent 7cb2014ff1
commit 67a6b2c29f
22 changed files with 1261 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "cool-unix",
"version": "8.0.9",
"version": "8.0.10",
"license": "MIT",
"scripts": {
"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

View File

@@ -405,6 +405,12 @@
"style": {
"navigationBarTitleText": "Cropper 图片裁剪"
}
},
{
"path": "other/canvas",
"style": {
"navigationBarTitleText": "Canvas 画布"
}
}
]
}

View File

@@ -0,0 +1,232 @@
<template>
<cl-page>
<cl-canvas
v-if="width > 0"
ref="canvasRef"
canvas-id="test"
:height="height"
:width="width"
@load="onCanvasLoad"
></cl-canvas>
<cl-footer>
<!-- #ifdef H5 -->
<cl-button type="primary" @click="previewImage">预览图片</cl-button>
<!-- #endif -->
<!-- #ifndef H5 -->
<cl-button type="primary" @click="saveImage">保存图片</cl-button>
<!-- #endif -->
</cl-footer>
</cl-page>
</template>
<script lang="ts" setup>
import { Canvas } from "@/uni_modules/cool-canvas";
import { useUi } from "@/uni_modules/cool-ui";
import { ref } from "vue";
const ui = useUi();
const canvasRef = ref<ClCanvasComponentPublicInstance | null>(null);
// 初始化为 0避免页面未完全渲染时 getWindowInfo 获取到的高度或宽度不准确(如已知固定高宽可直接赋值)
const height = ref(0);
const width = ref(0);
function onCanvasLoad(canvas: Canvas) {
canvas
.image({
x: 0,
y: 0,
width: width.value,
height: height.value,
url: "/static/demo/canvas/bg.png"
})
.image({
x: 0,
y: 0,
width: width.value,
height: height.value,
url: "/static/demo/canvas/light.png"
})
.image({
x: (width.value - 226) / 2,
y: 60,
width: 226,
height: 77,
url: "/static/demo/canvas/text-yqhy.png"
})
.image({
x: (width.value - 325) / 2,
y: 125,
width: 325,
height: 77,
url: "/static/demo/canvas/text-dezk.png"
})
.image({
x: (width.value - 196) / 2,
y: 190,
width: 196,
height: 62,
url: "/static/demo/canvas/text-xrfl.png"
})
.image({
x: (width.value - 374) / 2 - 1,
y: 500,
width: 374,
height: 220,
url: "/static/demo/canvas/rp-t.png"
})
.image({
x: (width.value - 327) / 2,
y: 280,
width: 327,
height: 430,
url: "/static/demo/canvas/bg-content.png"
})
.image({
x: 30,
y: 240,
width: 114,
height: 120,
url: "/static/demo/canvas/gold-l.png"
})
.image({
x: width.value - 106 - 50,
y: 240,
width: 106,
height: 107,
url: "/static/demo/canvas/gold-r.png"
})
.image({
x: (width.value - 350) / 2,
y: 595,
width: 350,
height: 122,
url: "/static/demo/canvas/rp-b.png"
})
.image({
x: (width.value - 208) / 2,
y: 631,
width: 208,
height: 89,
url: "/static/demo/canvas/invite-btn.png"
})
.div({
x: (width.value - 276) / 2,
y: 335,
width: 276,
height: 210,
backgroundColor: "#fff",
radius: 10
})
.text({
x: 0,
y: 350,
width: width.value,
content: "新人专享",
color: "#F73035",
textAlign: "center",
fontSize: 28,
fontWeight: "bold"
})
.text({
x: 0,
y: 390,
width: width.value,
content: "限时领券30元",
color: "#F73035",
textAlign: "center",
fontSize: 16
})
.image({
x: (width.value - 246) / 2,
y: 432,
width: 246,
height: 98,
url: "/static/demo/canvas/coupon.png"
})
.text({
x: (width.value - 246) / 2,
y: 435,
content: "领券",
width: 34,
color: "#fff",
fontSize: 11,
textAlign: "center"
})
.text({
x: (width.value - 246) / 2,
y: 454,
width: 86,
content: "80",
color: "#604E44",
fontSize: 46,
textAlign: "center",
fontWeight: "bold"
})
.text({
x: (width.value - 246) / 2 + 86,
y: 459,
width: 246 - 86,
content: "新人专享优惠券",
color: "#604E44",
fontSize: 18,
textAlign: "center"
})
.text({
x: (width.value - 246) / 2 + 86,
y: 489,
width: 246 - 86,
content: "2021.04.30-2021.06.30",
color: "#604E44",
fontSize: 12,
textAlign: "center"
})
.text({
x: 0,
y: 560,
width: width.value,
content: "邀请好友双方均可获得20元优惠券",
color: "#756056",
fontSize: 15,
textAlign: "center"
})
.text({
x: 0,
y: 300,
width: width.value,
content: " 专属新人福利 ",
color: "#956056",
textAlign: "center",
opacity: 0.7
})
.draw();
}
function previewImage() {
canvasRef.value!.previewImage();
}
function saveImage() {
canvasRef.value!.saveImage();
}
onReady(() => {
// 同上
height.value = uni.getWindowInfo().windowHeight;
width.value = uni.getWindowInfo().windowWidth;
ui.showConfirm({
title: "提示",
message: "本页面内容由 canvas 渲染生成,是否立即预览图片效果?",
confirmText: "预览",
callback(action) {
if (action == "confirm") {
canvasRef.value!.previewImage();
}
}
});
});
</script>

View File

@@ -73,6 +73,7 @@
</view>
</view>
<!-- 自定义底部导航栏 -->
<tabbar></tabbar>
<!-- 主题设置 -->
@@ -400,6 +401,11 @@ const data = computed<Item[]>(() => {
icon: "crop-line",
path: "/pages/demo/other/cropper"
},
{
label: t("Canvas"),
icon: "markup-line",
path: "/pages/demo/other/canvas"
},
{
label: t("富文本"),
icon: "text-snippet",

View File

@@ -236,12 +236,13 @@
</cl-list>
</view>
<!-- 自定义底部导航栏 -->
<tabbar></tabbar>
</cl-page>
</template>
<script setup lang="ts">
import { isDark, isMp, parseClass, router, useStore } from "@/cool";
import { router, useStore } from "@/cool";
import { t } from "@/locale";
import { useUi } from "@/uni_modules/cool-ui";
import Tabbar from "@/components/tabbar.uvue";

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
static/demo/canvas/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
static/demo/canvas/rp-b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/demo/canvas/rp-t.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,100 @@
<template>
<view class="cl-canvas">
<canvas
ref="canvasRef"
:id="canvasId"
:style="{ width: width + 'px', height: height + 'px' }"
></canvas>
</view>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Canvas, useCanvas } from "../../hooks";
import { canvasToPng } from "@/cool";
import { t } from "@/locale";
import { useUi } from "@/uni_modules/cool-ui";
const props = defineProps({
canvasId: {
type: String,
default: ""
},
height: {
type: Number,
default: 300
},
width: {
type: Number,
default: 300
}
});
const emit = defineEmits<{
(e: "load", canvas: Canvas): void;
}>();
const ui = useUi();
// 画布操作实例
const canvas = useCanvas(props.canvasId);
// 画布组件
const canvasRef = ref<UniElement | null>(null);
// 加载画布
function load() {
canvas.create().then(() => {
canvas.height(props.height);
canvas.width(props.width);
emit("load", canvas);
});
}
// 创建图片
async function createImage() {
return canvasToPng(canvasRef.value!);
}
// 预览图片
async function previewImage() {
const url = await createImage();
uni.previewImage({
urls: [url]
});
}
// 保存图片
async function saveImage() {
const url = await createImage();
uni.saveImageToPhotosAlbum({
filePath: url,
success: () => {
ui.showToast({
message: t("保存图片成功"),
type: "success"
});
},
fail: (err) => {
console.error("[cl-canvas]", err);
ui.showToast({
message: t("保存图片失败"),
type: "error"
});
}
});
}
onMounted(() => {
load();
});
defineExpose({
createImage,
saveImage,
previewImage
});
</script>

View File

@@ -0,0 +1,813 @@
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);
};

View File

@@ -0,0 +1 @@
export * from "./hooks";

View File

@@ -0,0 +1,5 @@
declare type ClCanvasComponentPublicInstance = {
saveImage: () => void;
previewImage: () => void;
createImage: () => Promise<string>;
};

View File

@@ -0,0 +1,95 @@
// 文本渲染参数
export type TextRenderOptions = {
x: number;
y: number;
height?: number;
width?: number;
content: string;
color?: string;
fontSize?: number;
fontFamily?: string;
fontWeight?: "normal" | "bold" | "bolder" | "lighter" | number;
textAlign?: "left" | "right" | "center";
overflow?: "ellipsis";
lineClamp?: number;
letterSpace?: number;
lineHeight?: number;
opacity?: number;
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 图片渲染参数
export type ImageRenderOptions = {
x: number;
y: number;
height: number;
width: number;
url: string;
mode?:
| "scaleToFill"
| "aspectFit"
| "aspectFill"
| "center"
| "top"
| "bottom"
| "left"
| "right"
| "topLeft"
| "topRight"
| "bottomLeft"
| "bottomRight";
radius?: number;
opacity?: number;
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 变换参数
export type TransformOptions = {
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 块渲染参数
export type DivRenderOptions = {
x: number;
y: number;
height?: number;
width?: number;
radius?: number;
backgroundColor?: string;
borderWidth?: number;
borderColor?: string;
opacity?: number;
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 裁剪图片参数
export type CropImageResult = {
sx: number;
sy: number;
sw: number;
sh: number;
dx: number;
dy: number;
dw: number;
dh: number;
};