Explorar o código

Perf: Optimization of cropping component result acquisition & optimization of cropping frame prompts (#7111)

* perf(cropper): enhance image cropping functionality and add output type support

* perf(cropper): enhance image cropping functionality and add output type support
JyQAQ hai 4 meses
pai
achega
59aabd956d

+ 11 - 22
apps/web-antd/src/adapter/component/index.ts

@@ -312,7 +312,16 @@ const withPreviewUpload = () => {
               Modal,
               {
                 open: open.value,
-                title: $t('ui.crop.title'),
+                title: h('div', {}, [
+                  $t('ui.crop.title'),
+                  h(
+                    'span',
+                    {
+                      class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
+                    },
+                    $t('ui.crop.titleTip', [aspectRatio]),
+                  ),
+                ]),
                 centered: true,
                 width: 548,
                 keyboard: false,
@@ -357,22 +366,6 @@ const withPreviewUpload = () => {
     });
   };
 
-  const base64ToBlob = (base64: Base64URLString) => {
-    try {
-      const [typeStr, encodeStr] = base64.split(',');
-      if (!typeStr || !encodeStr) return;
-      const mime = typeStr.match(/:(.*?);/)?.[1];
-      const raw = window.atob(encodeStr);
-      const rawLength = raw.length;
-      const uInt8Array = new Uint8Array(rawLength);
-      for (let i = 0; i < rawLength; ++i) {
-        uInt8Array[i] = raw.codePointAt(i) as number;
-      }
-      return new Blob([uInt8Array], { type: mime });
-    } catch {
-      return undefined;
-    }
-  };
   return defineComponent({
     name: Upload.name,
     emits: ['update:modelValue'],
@@ -408,12 +401,8 @@ const withPreviewUpload = () => {
         ) {
           file.status = 'removed';
           // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
-          const base64 = await cropImage(originFileList[0], attrs.aspectRatio);
+          const blob = await cropImage(originFileList[0], attrs.aspectRatio);
           return new Promise((resolve, reject) => {
-            if (!base64) {
-              return reject(new Error($t('ui.crop.cancel')));
-            }
-            const blob = base64ToBlob(base64 as string);
             if (!blob) {
               return reject(new Error($t('ui.crop.errorTip')));
             }

+ 40 - 17
packages/effects/common-ui/src/components/cropper/cropper.vue

@@ -523,20 +523,25 @@ const handleImageLoad = () => {
  * 裁剪图片
  * @param {'image/jpeg' | 'image/png'} format - 输出图片格式
  * @param {number} quality - 压缩质量(0-1)
+ * @param {'blob' | 'base64'} outputType - 输出类型
  * @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
  * @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
  */
 const getCropImage = async (
   format: 'image/jpeg' | 'image/png' = 'image/jpeg',
   quality: number = 0.92,
+  outputType: 'base64' | 'blob' = 'blob',
   targetWidth?: number,
   targetHeight?: number,
-): Promise<string | undefined> => {
+): Promise<Blob | string | undefined> => {
   if (!props.img || !bgImageRef.value || !containerRef.value) return;
 
+  // 质量参数边界修正:强制限制在 0-1 区间,防止传入非法值报错
+  const validQuality = Math.max(0, Math.min(1, quality));
+
   // 创建临时图片对象获取原始尺寸
   const tempImg = new Image();
-  // Only set crossOrigin for cross-origin URLs that need CORS
+  // 跨域图片处理:仅对非同源的网络图片设置跨域匿名
   if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
     try {
       const url = new URL(props.img);
@@ -544,7 +549,7 @@ const getCropImage = async (
         tempImg.crossOrigin = 'anonymous';
       }
     } catch {
-      // Invalid URL, proceed without crossOrigin
+      // Invalid URL,跳过跨域配置,不中断执行
     }
   }
 
@@ -553,7 +558,7 @@ const getCropImage = async (
     const timeout = setTimeout(() => {
       tempImg.removeEventListener('load', handleLoad);
       tempImg.removeEventListener('error', handleError);
-      reject(new Error('图片加载超时'));
+      reject(new Error('图片加载超时,超时时间10秒'));
     }, 10_000);
     const handleLoad = () => {
       clearTimeout(timeout);
@@ -571,7 +576,6 @@ const getCropImage = async (
 
     tempImg.addEventListener('load', handleLoad);
     tempImg.addEventListener('error', handleError);
-
     tempImg.src = props.img;
   });
 
@@ -595,11 +599,11 @@ const getCropImage = async (
   const cropOnImgX = cropLeft - imgOffsetX;
   const cropOnImgY = cropTop - imgOffsetY;
 
-  // 4. 计算渲染图片到原始图片的缩放比例(关键:保留原始像素)
+  // 4. 计算渲染图片到原始图片的缩放比例(保留原始像素)
   const scaleX = tempImg.width / renderedImgWidth;
   const scaleY = tempImg.height / renderedImgHeight;
 
-  // 5. 映射到原始图片的裁剪区域(精确到原始像素)
+  // 5. 映射到原始图片的裁剪区域(精确到原始像素,防止越界
   const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
   const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
   const originalCropWidth = Math.min(
@@ -611,27 +615,32 @@ const getCropImage = async (
     tempImg.height - originalCropY,
   );
 
-  // 6. 处理高清屏适配(关键:解决Retina屏模糊)
+  // 边界校验:裁剪尺寸非法则返回
+  if (originalCropWidth <= 0 || originalCropHeight <= 0) return;
+
+  // 6. 处理高清屏适配(解决Retina屏模糊)
   const dpr = window.devicePixelRatio || 1;
 
-  // 最终画布尺寸(优先使用原始裁剪尺寸,或目标尺寸)
-  const finalWidth = targetWidth || originalCropWidth;
-  const finalHeight = targetHeight || originalCropHeight;
+  // 最终画布尺寸(优先使用传入的目标尺寸,无则用原始裁剪尺寸)
+  const finalWidth = targetWidth ? Math.max(1, targetWidth) : originalCropWidth;
+  const finalHeight = targetHeight
+    ? Math.max(1, targetHeight)
+    : originalCropHeight;
 
-  // 创建画布(乘以设备像素比,保证高清)
+  // 创建画布并获取绘制上下文
   const canvas = document.createElement('canvas');
   const ctx = canvas.getContext('2d');
   if (!ctx) return;
 
-  // 画布物理尺寸(适配高清屏
+  // 画布物理尺寸(乘以设备像素比,保证高清无模糊
   canvas.width = finalWidth * dpr;
   canvas.height = finalHeight * dpr;
 
-  // 画布显示尺寸(视觉尺寸)
+  // 画布显示尺寸(视觉尺寸,和最终展示一致
   canvas.style.width = `${finalWidth}px`;
   canvas.style.height = `${finalHeight}px`;
 
-  // 缩放上下文(适配DPR)
+  // 缩放画布上下文,适配高清屏DPR
   ctx.scale(dpr, dpr);
 
   // 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
@@ -647,8 +656,22 @@ const getCropImage = async (
     finalHeight, // 画布绘制高度(目标尺寸)
   );
 
-  // 8. 导出图片(指定质量,平衡清晰度和体积)
-  return canvas.toDataURL(format, quality);
+  try {
+    return outputType === 'base64'
+      ? canvas.toDataURL(format, validQuality)
+      : new Promise<Blob>((resolve) => {
+          canvas.toBlob(
+            (blob) => {
+              // 兜底:如果blob生成失败,返回空Blob(防止null)
+              resolve(blob || new Blob([], { type: format }));
+            },
+            format,
+            validQuality,
+          );
+        });
+  } catch (error) {
+    console.error('图片导出失败:', error);
+  }
 };
 
 // 监听比例变化,重新调整裁剪框

+ 1 - 0
packages/locales/src/langs/en-US/ui.json

@@ -56,6 +56,7 @@
   },
   "crop": {
     "title": "Image Cropping",
+    "titleTip": "Cropping Ratio {0}",
     "confirm": "Crop",
     "cancel": "Cancel cropping",
     "errorTip": "Cropping error"

+ 1 - 0
packages/locales/src/langs/zh-CN/ui.json

@@ -56,6 +56,7 @@
   },
   "crop": {
     "title": "图片裁剪",
+    "titleTip": "裁剪比例 {0}",
     "confirm": "裁剪",
     "cancel": "取消裁剪",
     "errorTip": "裁剪错误"

+ 11 - 22
playground/src/adapter/component/index.ts

@@ -312,7 +312,16 @@ const withPreviewUpload = () => {
               Modal,
               {
                 open: open.value,
-                title: $t('ui.crop.title'),
+                title: h('div', {}, [
+                  $t('ui.crop.title'),
+                  h(
+                    'span',
+                    {
+                      class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
+                    },
+                    $t('ui.crop.titleTip', [aspectRatio]),
+                  ),
+                ]),
                 centered: true,
                 width: 548,
                 keyboard: false,
@@ -357,22 +366,6 @@ const withPreviewUpload = () => {
     });
   };
 
-  const base64ToBlob = (base64: Base64URLString) => {
-    try {
-      const [typeStr, encodeStr] = base64.split(',');
-      if (!typeStr || !encodeStr) return;
-      const mime = typeStr.match(/:(.*?);/)?.[1];
-      const raw = window.atob(encodeStr);
-      const rawLength = raw.length;
-      const uInt8Array = new Uint8Array(rawLength);
-      for (let i = 0; i < rawLength; ++i) {
-        uInt8Array[i] = raw.codePointAt(i) as number;
-      }
-      return new Blob([uInt8Array], { type: mime });
-    } catch {
-      return undefined;
-    }
-  };
   return defineComponent({
     name: Upload.name,
     emits: ['update:modelValue'],
@@ -408,12 +401,8 @@ const withPreviewUpload = () => {
         ) {
           file.status = 'removed';
           // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
-          const base64 = await cropImage(originFileList[0], attrs.aspectRatio);
+          const blob = await cropImage(originFileList[0], attrs.aspectRatio);
           return new Promise((resolve, reject) => {
-            if (!base64) {
-              return reject(new Error($t('ui.crop.cancel')));
-            }
-            const blob = base64ToBlob(base64 as string);
             if (!blob) {
               return reject(new Error($t('ui.crop.errorTip')));
             }

+ 5 - 1
playground/src/views/examples/cropper/index.vue

@@ -44,7 +44,11 @@ const cropImage = async () => {
   if (!cropperRef.value) return;
   cropLoading.value = true;
   try {
-    cropperImg.value = await cropperRef.value.getCropImage();
+    cropperImg.value = await cropperRef.value.getCropImage(
+      'image/jpeg',
+      0.92,
+      'base64',
+    );
   } catch (error) {
     console.error('图片裁剪失败:', error);
   } finally {