优化 canvasToPng

This commit is contained in:
icssoa
2025-07-29 13:26:31 +08:00
parent 28a6627224
commit 3b0e006ca0
8 changed files with 215 additions and 210 deletions

View File

@@ -578,6 +578,23 @@ export function random(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
/**
* 将base64转换为blob
* @param data base64数据
* @returns blob数据
*/
export function base64ToBlob(data: string, type: string = "image/jpeg"): Blob {
// #ifdef H5
let bytes = window.atob(data.split(",")[1]);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type });
// #endif
}
/** /**
* 检查是否为小程序环境 * 检查是否为小程序环境
* @returns 是否为小程序环境 * @returns 是否为小程序环境

81
cool/utils/file.ts Normal file
View File

@@ -0,0 +1,81 @@
import { base64ToBlob } from "./comm";
export type CanvasToPngOptions = {
canvasId: string;
proxy?: ComponentPublicInstance;
canvasRef: UniElement;
};
/**
* 将canvas转换为png图片
* @param options 转换参数
* @returns 图片路径
*/
export function canvasToPng(options: CanvasToPngOptions): Promise<string> {
return new Promise((resolve) => {
// #ifdef APP
options.canvasRef.parentElement!.takeSnapshot({
success(res) {
resolve(res.tempFilePath);
},
fail(err) {
console.error(err);
resolve("");
}
});
// #endif
// #ifdef H5
const url = URL.createObjectURL(
base64ToBlob(
(options.canvasRef as unknown as HTMLCanvasElement)
.querySelector("canvas")
?.toDataURL("image/png", 1) ?? ""
)
);
resolve(url);
// #endif
// #ifdef MP
uni.createCanvasContextAsync({
id: options.canvasId,
component: options.proxy,
success(context) {
// 获取2D绘图上下文
const ctx = context.getContext("2d")!;
const canvas = ctx.canvas;
// 将canvas转换为base64格式的PNG图片数据
const data = canvas.toDataURL("image/png", 1);
// 获取base64数据部分(去掉data:image/png;base64,前缀)
const bdataBase64 = data.split(",")[1];
// 获取文件系统管理器
const fileMg = uni.getFileSystemManager();
// 生成临时文件路径
// @ts-ignore
const filepath = `${wx.env.USER_DATA_PATH}/${uuid()}.png`;
// 将base64数据写入文件
fileMg.writeFile({
filePath: filepath,
data: bdataBase64,
encoding: "base64",
success() {
// 写入成功返回文件路径
resolve(filepath);
},
fail() {
// 写入失败返回空字符串
resolve("");
}
});
},
fail(err) {
console.error(err);
resolve("");
}
});
// #endif
});
}

View File

@@ -3,3 +3,4 @@ export * from "./storage";
export * from "./path"; export * from "./path";
export * from "./day"; export * from "./day";
export * from "./parse"; export * from "./parse";
export * from "./file";

View File

@@ -2,8 +2,8 @@
<cl-page> <cl-page>
<cl-sign <cl-sign
ref="signRef" ref="signRef"
:height="isFullscreen ? windowHeight - 200 : 200"
:width="windowWidth" :width="windowWidth"
:fullscreen="isFullscreen"
:enable-brush="isBrush" :enable-brush="isBrush"
></cl-sign> ></cl-sign>
@@ -30,19 +30,21 @@
import { ref } from "vue"; import { ref } from "vue";
import DemoItem from "../components/item.uvue"; import DemoItem from "../components/item.uvue";
const { windowWidth } = uni.getWindowInfo(); const { windowWidth, windowHeight } = uni.getWindowInfo();
const isFullscreen = ref(false); const isFullscreen = ref(false);
const isBrush = ref(true); const isBrush = ref(true);
const signRef = ref<ClSignComponentPublicInstance | null>(null); const signRef = ref<ClSignComponentPublicInstance | null>(null);
function clear() { function clear() {
signRef.value?.clear(); signRef.value!.clear();
} }
function preview() { function preview() {
signRef.value?.toPng().then((res) => { signRef.value!.toPng().then((url) => {
console.log(res); uni.previewImage({
urls: [url]
});
}); });
} }
</script> </script>

9
types/uni-app.d.ts vendored
View File

@@ -377,6 +377,15 @@ declare const onUnhandledRejection: (
declare const onUnload: (hook: () => any, target?: ComponentInternalInstance | null) => void; declare const onUnload: (hook: () => any, target?: ComponentInternalInstance | null) => void;
declare interface UniElement { declare interface UniElement {
firstChild: UniElement;
lastChild: UniElement;
previousSibling: UniElement;
parentElement: UniElement;
children: UniElement[];
attributes: Map<string, any>;
dataset: Map<string, any>;
style: CSSStyleDeclaration;
classList: string[];
takeSnapshot(options: { takeSnapshot(options: {
success: (res: { tempFilePath: string }) => void; success: (res: { tempFilePath: string }) => void;
fail: (err: { errCode: number; errMsg: string }) => void; fail: (err: { errCode: number; errMsg: string }) => void;

View File

@@ -1,8 +1,5 @@
<template> <template>
<view <view :style="{ width: getPx(props.width) + 'px', height: getPx(props.height) + 'px' }">
ref="qrcodeRef"
:style="{ width: getPx(props.width) + 'px', height: getPx(props.height) + 'px' }"
>
<canvas <canvas
ref="canvasRef" ref="canvasRef"
:canvas-id="qrcodeId" :canvas-id="qrcodeId"
@@ -22,13 +19,13 @@ import {
nextTick, nextTick,
computed, computed,
type PropType, type PropType,
onUnmounted onUnmounted,
shallowRef
} from "vue"; } from "vue";
import { drawQrcode, type QrcodeOptions } from "./draw"; import { drawQrcode, type QrcodeOptions } from "./draw";
import { getPx, isHarmony, uuid } from "@/cool"; import { canvasToPng, getPx, isHarmony, uuid } from "@/cool";
import type { ClQrcodeMode } from "../../types"; import type { ClQrcodeMode } from "../../types";
import { base64ToBlob } from "./utils";
defineOptions({ defineOptions({
name: "cl-qrcode" name: "cl-qrcode"
@@ -97,11 +94,8 @@ const { proxy } = getCurrentInstance()!;
// 二维码组件id // 二维码组件id
const qrcodeId = ref<string>("cl-qrcode-" + uuid()); const qrcodeId = ref<string>("cl-qrcode-" + uuid());
// 二维码组件元素
const qrcodeRef = ref<UniElement | null>(null);
// 二维码组件画布 // 二维码组件画布
const canvasRef = ref(null); const canvasRef = shallowRef<UniElement | null>(null);
/** /**
* 主绘制方法,根据当前 props 生成二维码并绘制到 canvas。 * 主绘制方法,根据当前 props 生成二维码并绘制到 canvas。
@@ -148,71 +142,10 @@ function drawer() {
* @param call 回调函数,返回图片路径,失败返回空字符串 * @param call 回调函数,返回图片路径,失败返回空字符串
*/ */
function toPng(): Promise<string> { function toPng(): Promise<string> {
return new Promise((resolve) => { return canvasToPng({
// #ifdef APP canvasId: qrcodeId.value,
qrcodeRef.value!.takeSnapshot({ proxy,
success(res) { canvasRef: canvasRef.value!
resolve(res.tempFilePath);
},
fail(err) {
console.error(err);
resolve("");
}
});
// #endif
// #ifdef H5
const url = URL.createObjectURL(
base64ToBlob(
(canvasRef.value as unknown as HTMLCanvasElement)
.querySelector("canvas")
?.toDataURL("image/png", 1) ?? ""
)
);
resolve(url);
// #endif
// #ifdef MP-WEIXIN
uni.createCanvasContextAsync({
id: qrcodeId.value,
component: proxy,
success(context) {
// 获取2D绘图上下文
const ctx = context.getContext("2d")!;
const canvas = ctx.canvas;
// 将canvas转换为base64格式的PNG图片数据
const data = canvas.toDataURL("image/png", 1);
// 获取base64数据部分(去掉data:image/png;base64,前缀)
const bdataBase64 = data.split(",")[1];
// 获取文件系统管理器
const fileMg = uni.getFileSystemManager();
// 生成临时文件路径
// @ts-ignore
const filepath = `${wx.env.USER_DATA_PATH}/${uuid()}.png`;
// 将base64数据写入文件
fileMg.writeFile({
filePath: filepath,
data: bdataBase64,
encoding: "base64",
success() {
// 写入成功返回文件路径
resolve(filepath);
},
fail() {
// 写入失败返回空字符串
resolve("");
}
});
},
fail(err) {
console.error(err);
resolve("");
}
});
// #endif
}); });
} }

View File

@@ -1,16 +0,0 @@
/**
* 将base64转换为blob
* @param data base64数据
* @returns blob数据
*/
export function base64ToBlob(data: string, type: string = "image/jpeg"): Blob {
// #ifdef H5
let bytes = window.atob(data.split(",")[1]);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type });
// #endif
}

View File

@@ -2,21 +2,22 @@
<view class="cl-sign" :class="[pt.className]"> <view class="cl-sign" :class="[pt.className]">
<canvas <canvas
class="cl-sign__canvas" class="cl-sign__canvas"
ref="canvasRef"
:id="canvasId" :id="canvasId"
:style="{ :style="{
height: `${size.height}px`, height: `${height}px`,
width: `${size.width}px` width: `${width}px`
}" }"
@touchstart="onTouchStart" @touchstart="onTouchStart"
@touchmove="onTouchMove" @touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd" @touchend="onTouchEnd"
></canvas> ></canvas>
</view> </view>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { parsePt, uuid } from "@/cool"; import { canvasToPng, parsePt, uuid } from "@/cool";
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, getCurrentInstance, onMounted, ref, shallowRef, watch } from "vue";
defineOptions({ defineOptions({
name: "cl-sign" name: "cl-sign"
@@ -76,22 +77,19 @@ const props = defineProps({
autoRotate: { autoRotate: {
type: Boolean, type: Boolean,
default: true default: true
},
// 是否全屏
fullscreen: {
type: Boolean,
default: false
} }
}); });
const emit = defineEmits(["change"]); const emit = defineEmits(["change"]);
const { proxy } = getCurrentInstance()!; const { proxy } = getCurrentInstance()!;
const { windowWidth, windowHeight } = uni.getWindowInfo();
// 触摸点类型 // 触摸点类型
type Point = { x: number; y: number; time: number }; type Point = { x: number; y: number; time: number };
// 矩形类型
type Rect = { left: number; top: number };
// 透传样式类型定义 // 透传样式类型定义
type PassThrough = { type PassThrough = {
className?: string; className?: string;
@@ -100,8 +98,8 @@ type PassThrough = {
// 解析透传样式配置 // 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt)); const pt = computed(() => parsePt<PassThrough>(props.pt));
// canvas组件上下文 // 签名组件画布
let canvasCtx: CanvasContext | null = null; const canvasRef = shallowRef<UniElement | null>(null);
// 绘图上下文 // 绘图上下文
let drawCtx: CanvasRenderingContext2D | null = null; let drawCtx: CanvasRenderingContext2D | null = null;
@@ -121,57 +119,73 @@ let currentStrokeWidth = ref(3);
// 速度缓冲数组(用于平滑速度变化) // 速度缓冲数组(用于平滑速度变化)
const velocityBuffer: number[] = []; const velocityBuffer: number[] = [];
// 当前是否为横屏 // canvas位置信息缓存
const isLandscape = ref(false); let canvasRect: Rect | null = null;
// 动态计算的画布尺寸 // 获取canvas位置信息
const size = computed(() => { function getCanvasRect(): Promise<Rect> {
if (props.fullscreen) { return new Promise((resolve) => {
const { windowWidth, windowHeight } = uni.getWindowInfo(); // #ifdef MP
uni.createSelectorQuery()
.in(proxy)
.select(`#${canvasId}`)
.boundingClientRect((rect: any) => {
if (rect) {
canvasRect = { left: rect.left, top: rect.top };
resolve(canvasRect!);
} else {
resolve({ left: 0, top: 0 });
}
})
.exec();
// #endif
return { // #ifndef MP
width: windowWidth, // 非小程序平台在需要时通过DOM获取位置信息
height: windowHeight canvasRect = { left: 0, top: 0 };
}; resolve(canvasRect!);
} // #endif
});
if (isLandscape.value) { }
const { windowWidth } = uni.getWindowInfo();
return {
width: windowWidth,
height: props.height
};
}
return {
width: props.width,
height: props.height
};
});
// 获取触摸点在canvas中的坐标 // 获取触摸点在canvas中的坐标
function getTouchPos(e: TouchEvent): Point { function getTouchPos(e: TouchEvent): Point {
const touch = e.touches[0]; const touch = e.touches[0];
// #ifdef H5
const rect = (e.target as any).getBoundingClientRect(); const rect = (e.target as any).getBoundingClientRect();
return { return {
x: touch.clientX - rect.left, x: touch.clientX - rect.left,
y: touch.clientY - rect.top, y: touch.clientY - rect.top,
time: Date.now() time: Date.now()
}; };
// #endif
// #ifndef H5
// 小程序中使用缓存的位置信息或直接使用触摸坐标
const left = canvasRect?.left ?? 0;
const top = canvasRect?.top ?? 0;
return {
x: touch.clientX - left,
y: touch.clientY - top,
time: Date.now()
};
// #endif
} }
// 计算速度并返回动态线条宽度 // 计算速度并返回动态线条宽度
function calculateStrokeWidth(currentPoint: Point): number { function calculateStrokeWidth(currentPoint: Point): number {
if (!lastPoint || !props.enableBrush) { if (lastPoint == null || !props.enableBrush) {
return props.strokeWidth; return props.strokeWidth;
} }
// 计算距离和时间差 // 计算距离和时间差
const distance = Math.sqrt( const distance = Math.sqrt(
Math.pow(currentPoint.x - lastPoint.x, 2) + Math.pow(currentPoint.y - lastPoint.y, 2) Math.pow(currentPoint.x - lastPoint!.x, 2) + Math.pow(currentPoint.y - lastPoint!.y, 2)
); );
const timeDelta = currentPoint.time - lastPoint.time; const timeDelta = currentPoint.time - lastPoint!.time;
if (timeDelta <= 0) return currentStrokeWidth.value; if (timeDelta <= 0) return currentStrokeWidth.value;
@@ -198,9 +212,17 @@ function calculateStrokeWidth(currentPoint: Point): number {
} }
// 触摸开始 // 触摸开始
function onTouchStart(e: TouchEvent) { async function onTouchStart(e: TouchEvent) {
e.preventDefault(); e.preventDefault();
isDrawing.value = true; isDrawing.value = true;
// #ifdef MP
// 小程序中,如果没有缓存位置信息,先获取
if (canvasRect == null) {
await getCanvasRect();
}
// #endif
lastPoint = getTouchPos(e); lastPoint = getTouchPos(e);
// 初始化线条宽度和清空速度缓冲 // 初始化线条宽度和清空速度缓冲
@@ -211,7 +233,7 @@ function onTouchStart(e: TouchEvent) {
// 触摸移动 // 触摸移动
function onTouchMove(e: TouchEvent) { function onTouchMove(e: TouchEvent) {
e.preventDefault(); e.preventDefault();
if (!isDrawing.value || !lastPoint || !drawCtx) return; if (!isDrawing.value || lastPoint == null || drawCtx == null) return;
const currentPoint = getTouchPos(e); const currentPoint = getTouchPos(e);
@@ -221,7 +243,7 @@ function onTouchMove(e: TouchEvent) {
// 绘制线条 // 绘制线条
drawCtx!.beginPath(); drawCtx!.beginPath();
drawCtx!.moveTo(lastPoint.x, lastPoint.y); drawCtx!.moveTo(lastPoint!.x, lastPoint!.y);
drawCtx!.lineTo(currentPoint.x, currentPoint.y); drawCtx!.lineTo(currentPoint.x, currentPoint.y);
drawCtx!.strokeStyle = props.strokeColor; drawCtx!.strokeStyle = props.strokeColor;
drawCtx!.lineWidth = strokeWidth; drawCtx!.lineWidth = strokeWidth;
@@ -240,86 +262,54 @@ function onTouchEnd(e: TouchEvent) {
lastPoint = null; lastPoint = null;
} }
// 判断横竖屏
function getOrientation() {
const { windowHeight, windowWidth } = uni.getWindowInfo();
// 判断是否为横屏(宽度大于高度)
isLandscape.value = windowWidth > windowHeight;
}
// 屏幕方向变化监听
function onOrientationChange() {
setTimeout(() => {
getOrientation();
// 重新初始化画布
if (props.autoRotate) {
initCanvas();
}
}, 300); // 延迟确保屏幕方向变化完成
}
// 清除画布 // 清除画布
function clear() { function clear() {
if (!drawCtx) return; if (drawCtx == null) return;
const { width, height } = size.value;
// #ifdef APP // #ifdef APP
drawCtx!.reset(); drawCtx!.reset();
// #endif // #endif
// #ifndef APP // #ifndef APP
drawCtx!.clearRect(0, 0, width, height); drawCtx!.clearRect(0, 0, props.width, props.height);
// #endif
// 获取设备像素比
const dpr = uni.getDeviceInfo().devicePixelRatio ?? 1;
// #ifndef H5
// 设置缩放比例
drawCtx!.scale(dpr, dpr);
// #endif // #endif
// 填充背景色 // 填充背景色
drawCtx!.fillStyle = props.backgroundColor; drawCtx!.fillStyle = props.backgroundColor;
drawCtx!.fillRect(0, 0, width, height); drawCtx!.fillRect(0, 0, props.width, props.height);
emit("change"); emit("change");
} }
// 获取签名图片 // 获取签名图片
function toPng(): Promise<string> { function toPng(): Promise<string> {
return new Promise((resolve, reject) => { return canvasToPng({
if (!canvasCtx) { proxy,
reject(new Error("Canvas context not initialized")); canvasId,
return; canvasRef: canvasRef.value!
}
uni.canvasToTempFilePath(
{
canvasId: canvasId,
success: (res) => {
resolve(res.tempFilePath);
},
fail: (err) => {
reject(err);
}
},
proxy
);
}); });
} }
// 初始化画布 // 初始化画布
function initCanvas() { function initCanvas() {
const { width, height } = size.value;
uni.createCanvasContextAsync({ uni.createCanvasContextAsync({
id: canvasId, id: canvasId,
component: proxy, component: proxy,
success: (context: CanvasContext) => { success: (context: CanvasContext) => {
// 设置canvas上下文
canvasCtx = context;
// 获取绘图上下文 // 获取绘图上下文
drawCtx = context.getContext("2d")!; drawCtx = context.getContext("2d")!;
// 设置宽高 // 设置宽高
drawCtx!.canvas.width = width; drawCtx!.canvas.width = props.width;
drawCtx!.canvas.height = height; drawCtx!.canvas.height = props.height;
// 优化渲染质量 // 优化渲染质量
drawCtx!.textBaseline = "middle"; drawCtx!.textBaseline = "middle";
@@ -328,38 +318,26 @@ function initCanvas() {
// 初始化背景 // 初始化背景
clear(); clear();
// #ifdef MP
// 小程序中初始化时获取canvas位置信息
getCanvasRect();
// #endif
} }
}); });
} }
onMounted(() => { onMounted(() => {
// 判断横屏竖屏
getOrientation();
// 初始化画布
initCanvas(); initCanvas();
// 监听屏幕方向变化
if (props.autoRotate) {
uni.onWindowResize(onOrientationChange);
}
// 监听全屏状态变化
watch( watch(
() => props.fullscreen, computed(() => [props.width, props.height]),
() => { () => {
initCanvas(); initCanvas();
} }
); );
}); });
onUnmounted(() => {
// 移除屏幕方向监听
if (props.autoRotate) {
uni.offWindowResize(onOrientationChange);
}
});
defineExpose({ defineExpose({
clear, clear,
toPng toPng