浏览代码

添加 cl-canvas 组件

icssoa 7 月之前
父节点
当前提交
67a6b2c29f

+ 1 - 1
package.json

@@ -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",

+ 6 - 0
pages.json

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

+ 232 - 0
pages/demo/other/canvas.uvue

@@ -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>

+ 6 - 0
pages/index/home.uvue

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

+ 2 - 1
pages/index/my.uvue

@@ -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";

二进制
static/demo/canvas/bg-content.png


二进制
static/demo/canvas/bg.png


二进制
static/demo/canvas/coupon.png


二进制
static/demo/canvas/gold-l.png


二进制
static/demo/canvas/gold-r.png


二进制
static/demo/canvas/invite-btn.png


二进制
static/demo/canvas/light.png


二进制
static/demo/canvas/rp-b.png


二进制
static/demo/canvas/rp-t.png


二进制
static/demo/canvas/text-dezk.png


二进制
static/demo/canvas/text-xrfl.png


二进制
static/demo/canvas/text-yqhy.png


+ 100 - 0
uni_modules/cool-canvas/components/cl-canvas/cl-canvas.uvue

@@ -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>

+ 813 - 0
uni_modules/cool-canvas/hooks/index.ts

@@ -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);
+};

+ 1 - 0
uni_modules/cool-canvas/index.ts

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

+ 5 - 0
uni_modules/cool-canvas/types/component.d.ts

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

+ 95 - 0
uni_modules/cool-canvas/types/index.ts

@@ -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;
+};