Ver código fonte

背景图矩形裁切

dsy 6 dias atrás
pai
commit
e7d24a7545

+ 309 - 1
src/views/book/courseware/create/components/SetBackground.vue

@@ -8,7 +8,12 @@
   >
     <div class="background-title">背景设置</div>
     <div class="set-background">
-      <div class="background-img">
+      <div
+        ref="backgroundImg"
+        class="background-img"
+        :class="{ 'is-crop-mode': cropMode }"
+        @mousedown="onBackgroundMouseDown"
+      >
         <div v-if="file_url" class="img-set" :style="{ top: imgTop, left: imgLeft, position: 'relative' }">
           <div class="dot top-left" @mousedown="dragStart($event, 'nwse-resize', 'top-left')"></div>
           <div class="horizontal-line" @mousedown="dragStart($event, 'ns-resize', 'top')"></div>
@@ -19,6 +24,7 @@
             :src="file_url"
             draggable="false"
             :style="{ width: imgWidth, height: imgHeight }"
+            @load="handleImageLoad"
             @mousedown="dragStart($event, 'move', 'move')"
           />
           <div class="vertical-line" @mousedown="dragStart($event, 'ew-resize', 'right')"></div>
@@ -26,6 +32,8 @@
           <div class="horizontal-line" @mousedown="dragStart($event, 'ns-resize', 'bottom')"></div>
           <div class="dot bottom-right" @mousedown="dragStart($event, 'nwse-resize', 'bottom-right')"></div>
         </div>
+        <div v-if="file_url && cropMode" class="crop-tip">在图片上拖拽框选裁切区域</div>
+        <div v-if="file_url && cropMode && hasCropSelection" class="crop-selection" :style="cropSelectionStyle"></div>
       </div>
 
       <div class="setup">
@@ -62,6 +70,11 @@
                 {{ mode.label }}
               </span>
             </div>
+            <div v-if="file_url" class="crop-actions">
+              <el-button size="mini" plain @click="startRectCrop">裁切</el-button>
+              <el-button v-if="cropMode" size="mini" @click="cancelRectCrop">取消</el-button>
+              <el-button v-if="cropMode" size="mini" type="primary" @click="applyRectCrop">应用</el-button>
+            </div>
           </div>
         </div>
         <div class="setup-item">
@@ -163,6 +176,22 @@ export default {
         startY: 0,
         type: '',
       },
+      cropMode: false,
+      crop: {
+        drawing: false,
+        startX: 0,
+        startY: 0,
+        x: 0,
+        y: 0,
+        width: 0,
+        height: 0,
+      },
+      imageDisplay: {
+        top: 0,
+        left: 0,
+        width: 0,
+        height: 0,
+      },
       file_url: '', // 背景图片地址
       background: {
         mode: 'image', // 背景模式
@@ -200,6 +229,17 @@ export default {
     imgHeight() {
       return `${this.imgData.height}px`;
     },
+    hasCropSelection() {
+      return this.crop.width > 1 && this.crop.height > 1;
+    },
+    cropSelectionStyle() {
+      return {
+        top: `${this.imageDisplay.top + this.crop.y}px`,
+        left: `${this.imageDisplay.left + this.crop.x}px`,
+        width: `${this.crop.width}px`,
+        height: `${this.crop.height}px`,
+      };
+    },
   },
   watch: {
     visible(newVal) {
@@ -214,12 +254,17 @@ export default {
         if (this.backgroundData && Object.keys(this.backgroundData).length) {
           this.background = { ...this.backgroundData };
         }
+        this.cropMode = false;
+        this.resetCropRect();
 
         this.$nextTick(() => {
           document.querySelector('.background-img').addEventListener('mousemove', this.mouseMove);
+          this.syncImageDisplayRect();
         });
       } else {
         document.querySelector('.background-img')?.removeEventListener('mousemove', this.mouseMove);
+        this.cropMode = false;
+        this.resetCropRect();
       }
     },
   },
@@ -241,6 +286,7 @@ export default {
      * @param {string} type
      */
     dragStart(event, cursor, type) {
+      if (this.cropMode) return;
       const { clientX, clientY } = event;
       this.drag = {
         dragging: true,
@@ -256,6 +302,25 @@ export default {
      * @param {MouseEvent} event
      */
     mouseMove(event) {
+      if (this.cropMode) {
+        if (!this.crop.drawing) return;
+        const point = this.getPointInImage(event);
+        if (!point) return;
+
+        const x = Math.min(this.crop.startX, point.x);
+        const y = Math.min(this.crop.startY, point.y);
+        const width = Math.abs(point.x - this.crop.startX);
+        const height = Math.abs(point.y - this.crop.startY);
+        this.crop = {
+          ...this.crop,
+          x,
+          y,
+          width,
+          height,
+        };
+        return;
+      }
+
       if (!this.drag.dragging) return;
       const { clientX, clientY } = event;
       const { startX, startY, type } = this.drag;
@@ -306,14 +371,226 @@ export default {
 
       this.drag.startX = clientX;
       this.drag.startY = clientY;
+      this.syncImageDisplayRect();
     },
     /**
      * 鼠标抬起
      */
     mouseUp() {
+      this.crop.drawing = false;
       this.drag.dragging = false;
       document.querySelector('.el-dialog__wrapper').style.cursor = 'auto';
     },
+    onBackgroundMouseDown(event) {
+      if (!this.cropMode || !this.file_url) return;
+      const point = this.getPointInImage(event);
+      if (!point) return;
+
+      this.crop = {
+        ...this.crop,
+        drawing: true,
+        startX: point.x,
+        startY: point.y,
+        x: point.x,
+        y: point.y,
+        width: 0,
+        height: 0,
+      };
+      event.preventDefault();
+    },
+    getPointInImage(event) {
+      const bg = this.$refs.backgroundImg;
+      if (!bg || this.imageDisplay.width <= 0 || this.imageDisplay.height <= 0) return null;
+
+      const bgRect = bg.getBoundingClientRect();
+      const rawX = event.clientX - bgRect.left - this.imageDisplay.left;
+      const rawY = event.clientY - bgRect.top - this.imageDisplay.top;
+
+      if (rawX < 0 || rawY < 0 || rawX > this.imageDisplay.width || rawY > this.imageDisplay.height) {
+        return null;
+      }
+
+      return {
+        x: Math.min(this.imageDisplay.width, Math.max(0, rawX)),
+        y: Math.min(this.imageDisplay.height, Math.max(0, rawY)),
+      };
+    },
+    syncImageDisplayRect() {
+      this.$nextTick(() => {
+        const bg = this.$refs.backgroundImg;
+        const img = this.$refs.img;
+        if (!bg || !img) {
+          this.imageDisplay = {
+            top: 0,
+            left: 0,
+            width: 0,
+            height: 0,
+          };
+          return;
+        }
+        const bgRect = bg.getBoundingClientRect();
+        const imgRect = img.getBoundingClientRect();
+        this.imageDisplay = {
+          top: imgRect.top - bgRect.top,
+          left: imgRect.left - bgRect.left,
+          width: imgRect.width,
+          height: imgRect.height,
+        };
+      });
+    },
+    resetCropRect() {
+      this.crop = {
+        drawing: false,
+        startX: 0,
+        startY: 0,
+        x: 0,
+        y: 0,
+        width: 0,
+        height: 0,
+      };
+    },
+    startRectCrop() {
+      if (!this.file_url) return;
+      this.cropMode = true;
+      this.syncImageDisplayRect();
+      this.$nextTick(() => {
+        const { width, height } = this.imageDisplay;
+        if (!width || !height) return;
+        this.crop = {
+          drawing: false,
+          startX: 0,
+          startY: 0,
+          x: width * 0.25,
+          y: height * 0.25,
+          width: width * 0.5,
+          height: height * 0.5,
+        };
+      });
+    },
+    cancelRectCrop() {
+      this.cropMode = false;
+      this.resetCropRect();
+    },
+    async applyRectCrop() {
+      if (!this.hasCropSelection) {
+        this.$message.warning('请先框选裁切区域');
+        return;
+      }
+
+      if (!this.file_url || !this.imageDisplay.width || !this.imageDisplay.height) return;
+
+      let sourceImg = null;
+      let revokeObjectURL = null;
+
+      try {
+        const cropSource = await this.getCropSourceImage();
+        sourceImg = cropSource.img;
+        revokeObjectURL = cropSource.revokeObjectURL;
+      } catch (error) {
+        this.$message.error('当前图片不支持裁切,请检查图片服务跨域配置');
+        return;
+      }
+
+      const naturalWidth = sourceImg.naturalWidth;
+      const naturalHeight = sourceImg.naturalHeight;
+      const displayWidth = this.imageDisplay.width;
+      const displayHeight = this.imageDisplay.height;
+
+      const sx = Math.max(0, Math.round((this.crop.x / displayWidth) * naturalWidth));
+      const sy = Math.max(0, Math.round((this.crop.y / displayHeight) * naturalHeight));
+      const sw = Math.max(1, Math.round((this.crop.width / displayWidth) * naturalWidth));
+      const sh = Math.max(1, Math.round((this.crop.height / displayHeight) * naturalHeight));
+      const safeWidth = Math.max(1, Math.min(sw, naturalWidth - sx));
+      const safeHeight = Math.max(1, Math.min(sh, naturalHeight - sy));
+
+      try {
+        const canvas = document.createElement('canvas');
+        canvas.width = safeWidth;
+        canvas.height = safeHeight;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) throw new Error('canvas context unavailable');
+        ctx.drawImage(sourceImg, sx, sy, safeWidth, safeHeight, 0, 0, safeWidth, safeHeight);
+
+        const blob = await new Promise((resolve, reject) => {
+          canvas.toBlob((result) => {
+            if (result) {
+              resolve(result);
+              return;
+            }
+            reject(new Error('toBlob failed'));
+          }, 'image/png');
+        });
+
+        const cropFile = new File([blob], `background-crop-${Date.now()}.png`, { type: 'image/png' });
+        const uploadPayload = {
+          filename: 'file',
+          file: cropFile,
+        };
+
+        const { file_info_list } = await fileUpload('Mid', uploadPayload, { isGlobalprogress: true });
+        if (!file_info_list || !file_info_list.length) {
+          throw new Error('upload failed');
+        }
+
+        this.file_url = file_info_list[0].file_url;
+        this.normalComputed(safeWidth, safeHeight);
+        this.cancelRectCrop();
+        this.$message.success('裁切成功');
+      } catch (error) {
+        this.$message.error('裁切失败');
+        console.error('Rect crop error:', error);
+      } finally {
+        revokeObjectURL?.();
+      }
+    },
+    async getCropSourceImage() {
+      try {
+        const img = await this.loadImage(this.file_url, 'anonymous');
+        return {
+          img,
+          revokeObjectURL: null,
+        };
+      } catch (crossOriginError) {
+        const blob = await this.fetchImageBlob(this.file_url);
+        const objectUrl = URL.createObjectURL(blob);
+
+        try {
+          const img = await this.loadImage(objectUrl);
+          return {
+            img,
+            revokeObjectURL: () => URL.revokeObjectURL(objectUrl),
+          };
+        } catch (loadBlobError) {
+          URL.revokeObjectURL(objectUrl);
+          throw loadBlobError;
+        }
+      }
+    },
+    loadImage(src, crossOrigin = '') {
+      return new Promise((resolve, reject) => {
+        const img = new Image();
+        if (crossOrigin) {
+          img.crossOrigin = crossOrigin;
+        }
+        img.onload = () => resolve(img);
+        img.onerror = () => reject(new Error('image load failed'));
+        img.src = src;
+      });
+    },
+    async fetchImageBlob(url) {
+      const response = await fetch(url, {
+        credentials: 'include',
+      });
+
+      if (!response.ok) {
+        throw new Error('fetch image blob failed');
+      }
+
+      return response.blob();
+    },
+    handleImageLoad() {
+      this.syncImageDisplayRect();
+    },
     /**
      * 计算圆角样式
      * @param {string} position 位置,top/bottom
@@ -414,10 +691,36 @@ export default {
   column-gap: 8px;
 
   .background-img {
+    position: relative;
     flex: 1;
     height: 410px;
     border: 1px dashed rgba(0, 0, 0, 8%);
 
+    &.is-crop-mode {
+      cursor: crosshair;
+    }
+
+    .crop-tip {
+      position: absolute;
+      top: 8px;
+      left: 8px;
+      z-index: 3;
+      padding: 2px 8px;
+      font-size: 12px;
+      color: #fff;
+      pointer-events: none;
+      background: rgba(0, 0, 0, 45%);
+      border-radius: 10px;
+    }
+
+    .crop-selection {
+      position: absolute;
+      z-index: 2;
+      pointer-events: none;
+      border: 1px dashed #fff;
+      box-shadow: 0 0 0 9999px rgba(0, 0, 0, 35%);
+    }
+
     .img-set {
       display: inline-grid;
       grid-template:
@@ -547,6 +850,11 @@ export default {
           }
         }
 
+        .crop-actions {
+          z-index: 3001;
+          display: flex;
+        }
+
         &.border-radius {
           display: grid;
           grid-template-rows: 1fr 1fr;