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