600 lines
18 KiB
Vue
600 lines
18 KiB
Vue
<template>
|
||
<view :class="['cropper-wrapper', { show: visible }]">
|
||
<!-- 显示层:用 image 展示图片,裁剪框覆盖其上,避免真机上 canvas 把裁剪框盖住 -->
|
||
<view
|
||
class="display-layer"
|
||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||
@touchstart="onTouchStart"
|
||
@touchmove="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
>
|
||
<image
|
||
v-if="imageSrc"
|
||
:src="imageSrc"
|
||
class="display-image"
|
||
mode="aspectFit"
|
||
></image>
|
||
</view>
|
||
|
||
<!-- 工作 canvas:放到屏幕外,只用于真正的绘制和导出 -->
|
||
<canvas
|
||
id="imageCropperCanvas"
|
||
canvas-id="imageCropperCanvas"
|
||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||
class="work-canvas"
|
||
></canvas>
|
||
|
||
<!-- 用于导出裁剪结果的离屏 canvas(必须有真实节点) -->
|
||
<canvas
|
||
id="tempCropCanvas"
|
||
canvas-id="tempCropCanvas"
|
||
:style="{ width: exportSize.width + 'px', height: exportSize.height + 'px' }"
|
||
class="temp-canvas"
|
||
></canvas>
|
||
|
||
<view
|
||
class="crop-frame"
|
||
:style="{
|
||
left: cropFrame.x + 'px',
|
||
top: cropFrame.y + 'px',
|
||
width: cropFrame.width + 'px',
|
||
height: cropFrame.height + 'px',
|
||
}"
|
||
@touchstart.stop.prevent="onFrameTouchStart"
|
||
@touchmove.stop.prevent="onFrameTouchMove"
|
||
@touchend.stop="onFrameTouchEnd"
|
||
>
|
||
<view class="resize-handle top-left" data-direction="tl"></view>
|
||
<view class="resize-handle top-right" data-direction="tr"></view>
|
||
<view class="resize-handle bottom-left" data-direction="bl"></view>
|
||
<view class="resize-handle bottom-right" data-direction="br"></view>
|
||
</view>
|
||
|
||
<view class="crop-controls" :style="{ bottom: 8 + safeAreaBottom + 'px' }">
|
||
<button @tap="cancelCrop">取消</button>
|
||
<button :disabled="isProcessing" @tap="confirmCrop">
|
||
{{ isProcessing ? '处理中...' : '确定' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, onMounted, nextTick, getCurrentInstance } from 'vue';
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
visible: boolean;
|
||
imageSrc: string;
|
||
outputWidth?: number; // 裁剪输出宽度
|
||
outputHeight?: number; // 裁剪输出高度
|
||
}>(),
|
||
{
|
||
outputWidth: 750, // 默认输出宽度
|
||
outputHeight: 750, // 默认输出高度
|
||
}
|
||
);
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:visible', value: boolean): void;
|
||
(e: 'confirm', filePath: string): void;
|
||
(e: 'cancel'): void;
|
||
}>();
|
||
|
||
const ctx = ref<UniApp.CanvasContext | null>(null);
|
||
const imgInfo = ref<UniApp.GetImageInfoSuccessData | null>(null);
|
||
|
||
const canvasWidth = ref(0);
|
||
const canvasHeight = ref(0);
|
||
const imageDisplay = ref({ x: 0, y: 0, width: 0, height: 0 }); // 图片在canvas上的显示位置和尺寸
|
||
const controlsReservePx = 64; // 给底部按钮预留空间,避免真机遮挡图片
|
||
|
||
const cropFrame = ref({ x: 0, y: 0, width: 0, height: 0 }); // 裁剪框位置和尺寸
|
||
const isDraggingImage = ref(false);
|
||
const isResizingFrame = ref(false);
|
||
const isMovingFrame = ref(false);
|
||
const startX = ref(0);
|
||
const startY = ref(0);
|
||
const lastFrame = ref({ x: 0, y: 0, width: 0, height: 0 }); // 用于记录上一次裁剪框的位置和大小
|
||
const resizeDirection = ref('');
|
||
const isProcessing = ref(false); // 裁剪处理中状态
|
||
|
||
const safeAreaTop = ref(0);
|
||
const safeAreaBottom = ref(0);
|
||
const componentProxy = getCurrentInstance()?.proxy as any;
|
||
const exportSize = ref({ width: 0, height: 0 });
|
||
|
||
const rpxToPx = (rpx: number) => {
|
||
// 按小程序 rpx 规则:750rpx = windowWidth(px)
|
||
if (!canvasWidth.value) return Math.max(1, Math.round(rpx));
|
||
return Math.max(1, Math.round((canvasWidth.value / 750) * rpx));
|
||
};
|
||
|
||
const getTouchPoint = (e: any) => {
|
||
const t = e?.touches?.[0] || e?.changedTouches?.[0] || {};
|
||
const x = t.x ?? t.clientX ?? t.pageX ?? 0;
|
||
const y = t.y ?? t.clientY ?? t.pageY ?? 0;
|
||
return { x, y };
|
||
};
|
||
|
||
const tryInitAndLoad = () => {
|
||
if (!props.visible) return;
|
||
if (!props.imageSrc) return;
|
||
if (!ctx.value) return;
|
||
if (!canvasWidth.value || !canvasHeight.value) return;
|
||
|
||
nextTick(() => {
|
||
exportSize.value = {
|
||
width: rpxToPx(props.outputWidth),
|
||
height: rpxToPx(props.outputHeight),
|
||
};
|
||
loadImage();
|
||
});
|
||
};
|
||
|
||
onMounted(() => {
|
||
// 获取设备信息,用于计算canvas大小和安全区
|
||
uni.getSystemInfo({
|
||
success: (res) => {
|
||
canvasWidth.value = res.windowWidth;
|
||
safeAreaTop.value = res.safeArea?.top || 0;
|
||
safeAreaBottom.value =
|
||
(res.screenHeight && res.safeArea ? res.screenHeight - res.safeArea.bottom : 0) || 0;
|
||
// 预留底部按钮区 + 安全区,避免按钮覆盖图片(尤其真机横屏)
|
||
const bottomReserve = controlsReservePx + safeAreaBottom.value;
|
||
canvasHeight.value = Math.max(1, res.windowHeight - bottomReserve);
|
||
// 自定义组件内的 canvas 必须传组件实例,否则会出现“黑屏/不绘制”
|
||
ctx.value = uni.createCanvasContext('imageCropperCanvas', componentProxy);
|
||
tryInitAndLoad();
|
||
},
|
||
});
|
||
});
|
||
|
||
watch(
|
||
[() => props.visible, () => props.imageSrc, canvasWidth, canvasHeight, ctx],
|
||
() => {
|
||
if (!props.visible) {
|
||
imgInfo.value = null;
|
||
return;
|
||
}
|
||
tryInitAndLoad();
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
const loadImage = () => {
|
||
uni.showLoading({ title: '加载中...' });
|
||
uni.getImageInfo({
|
||
src: props.imageSrc,
|
||
success: (res) => {
|
||
imgInfo.value = res;
|
||
drawImage();
|
||
uni.hideLoading();
|
||
},
|
||
fail: (err) => {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: '图片加载失败', icon: 'none' });
|
||
console.error('图片加载失败', err);
|
||
cancelCrop();
|
||
},
|
||
});
|
||
};
|
||
|
||
const drawImage = () => {
|
||
if (!ctx.value || !imgInfo.value) return;
|
||
|
||
const { path, width: originalWidth, height: originalHeight } = imgInfo.value;
|
||
|
||
// 计算图片在Canvas上的显示尺寸,使其适应Canvas,并居中
|
||
const canvasRatio = canvasWidth.value / canvasHeight.value;
|
||
const imageRatio = originalWidth / originalHeight;
|
||
|
||
let displayWidth = 0;
|
||
let displayHeight = 0;
|
||
|
||
if (imageRatio > canvasRatio) {
|
||
displayWidth = canvasWidth.value;
|
||
displayHeight = canvasWidth.value / imageRatio;
|
||
} else {
|
||
displayHeight = canvasHeight.value;
|
||
displayWidth = canvasHeight.value * imageRatio;
|
||
}
|
||
|
||
imageDisplay.value = {
|
||
x: (canvasWidth.value - displayWidth) / 2,
|
||
y: (canvasHeight.value - displayHeight) / 2,
|
||
width: displayWidth,
|
||
height: displayHeight,
|
||
};
|
||
|
||
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||
ctx.value.drawImage(
|
||
path,
|
||
imageDisplay.value.x,
|
||
imageDisplay.value.y,
|
||
imageDisplay.value.width,
|
||
imageDisplay.value.height
|
||
);
|
||
|
||
// 初始化裁剪框为图片显示区域的大小
|
||
cropFrame.value = {
|
||
x: imageDisplay.value.x,
|
||
y: imageDisplay.value.y,
|
||
width: imageDisplay.value.width,
|
||
height: imageDisplay.value.height,
|
||
};
|
||
|
||
ctx.value.draw();
|
||
};
|
||
|
||
const onTouchStart = (e: any) => {
|
||
if (!imgInfo.value) return;
|
||
const { x, y } = getTouchPoint(e);
|
||
startX.value = x;
|
||
startY.value = y;
|
||
|
||
// 判断是否在拖动图片
|
||
if (
|
||
x > imageDisplay.value.x &&
|
||
x < imageDisplay.value.x + imageDisplay.value.width &&
|
||
y > imageDisplay.value.y &&
|
||
y < imageDisplay.value.y + imageDisplay.value.height
|
||
) {
|
||
isDraggingImage.value = true;
|
||
// 记录图片当前位置,用于计算偏移
|
||
lastFrame.value = { ...imageDisplay.value };
|
||
}
|
||
};
|
||
|
||
const onTouchMove = (e: any) => {
|
||
if (!isDraggingImage.value || !imgInfo.value || !ctx.value) return;
|
||
|
||
const { x, y } = getTouchPoint(e);
|
||
const deltaX = x - startX.value;
|
||
const deltaY = y - startY.value;
|
||
|
||
let newX = lastFrame.value.x + deltaX;
|
||
let newY = lastFrame.value.y + deltaY;
|
||
|
||
// 边界检查:确保图片不会完全移出canvas
|
||
if (newX > canvasWidth.value - imageDisplay.value.x) {
|
||
newX = canvasWidth.value - imageDisplay.value.x;
|
||
}
|
||
if (newY > canvasHeight.value - imageDisplay.value.y) {
|
||
newY = canvasHeight.value - imageDisplay.value.y;
|
||
}
|
||
|
||
if (newX < -imageDisplay.value.width + imageDisplay.value.x) {
|
||
newX = -imageDisplay.value.width + imageDisplay.value.x;
|
||
}
|
||
if (newY < -imageDisplay.value.height + imageDisplay.value.y) {
|
||
newY = -imageDisplay.value.height + imageDisplay.value.y;
|
||
}
|
||
|
||
imageDisplay.value.x = newX;
|
||
imageDisplay.value.y = newY;
|
||
|
||
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||
ctx.value.drawImage(
|
||
imgInfo.value.path,
|
||
imageDisplay.value.x,
|
||
imageDisplay.value.y,
|
||
imageDisplay.value.width,
|
||
imageDisplay.value.height
|
||
);
|
||
ctx.value.draw(true); // true表示保留上一次绘制内容
|
||
};
|
||
|
||
const onTouchEnd = () => {
|
||
isDraggingImage.value = false;
|
||
};
|
||
|
||
// eslint-disable-next-line complexity
|
||
const onFrameTouchMove = (e: any) => {
|
||
if (!imgInfo.value) return;
|
||
const { x, y } = getTouchPoint(e);
|
||
const deltaX = x - startX.value;
|
||
const deltaY = y - startY.value;
|
||
|
||
if (isMovingFrame.value) {
|
||
// 移动裁剪框
|
||
let newX = lastFrame.value.x + deltaX;
|
||
let newY = lastFrame.value.y + deltaY;
|
||
|
||
// 边界检查
|
||
if (newX < 0) newX = 0;
|
||
if (newY < 0) newY = 0;
|
||
if (newX + cropFrame.value.width > canvasWidth.value)
|
||
newX = canvasWidth.value - cropFrame.value.width;
|
||
if (newY + cropFrame.value.height > canvasHeight.value)
|
||
newY = canvasHeight.value - cropFrame.value.height;
|
||
|
||
cropFrame.value.x = newX;
|
||
cropFrame.value.y = newY;
|
||
} else if (isResizingFrame.value) {
|
||
// 缩放裁剪框
|
||
let newX = lastFrame.value.x;
|
||
let newY = lastFrame.value.y;
|
||
let newWidth = lastFrame.value.width;
|
||
let newHeight = lastFrame.value.height;
|
||
|
||
const minSize = 50; // 最小尺寸
|
||
|
||
switch (resizeDirection.value) {
|
||
case 'tl': // Top-left
|
||
newX = Math.min(x, lastFrame.value.x + lastFrame.value.width - minSize);
|
||
newY = Math.min(y, lastFrame.value.y + lastFrame.value.height - minSize);
|
||
newWidth = lastFrame.value.x + lastFrame.value.width - newX;
|
||
newHeight = lastFrame.value.y + lastFrame.value.height - newY;
|
||
break;
|
||
case 'tr': // Top-right
|
||
newX = lastFrame.value.x;
|
||
newY = Math.min(startY.value, lastFrame.value.y + lastFrame.value.height - minSize, y);
|
||
newWidth = Math.max(minSize, lastFrame.value.width + deltaX);
|
||
newHeight = lastFrame.value.y + lastFrame.value.height - newY;
|
||
break;
|
||
case 'bl': // Bottom-left
|
||
newX = Math.min(startX.value, lastFrame.value.x + lastFrame.value.width - minSize, x);
|
||
newY = lastFrame.value.y;
|
||
newWidth = lastFrame.value.x + lastFrame.value.width - newX;
|
||
newHeight = Math.max(minSize, lastFrame.value.height + deltaY);
|
||
break;
|
||
case 'br': // Bottom-right
|
||
newX = lastFrame.value.x;
|
||
newY = lastFrame.value.y;
|
||
newWidth = Math.max(minSize, lastFrame.value.width + deltaX);
|
||
newHeight = Math.max(minSize, lastFrame.value.height + deltaY);
|
||
break;
|
||
}
|
||
|
||
// 裁剪框不能超出canvas边界
|
||
if (newX < 0) newX = 0;
|
||
if (newY < 0) newY = 0;
|
||
if (newX + newWidth > canvasWidth.value) newWidth = canvasWidth.value - newX;
|
||
if (newY + newHeight > canvasHeight.value) newHeight = canvasHeight.value - newY;
|
||
|
||
cropFrame.value = { x: newX, y: newY, width: newWidth, height: newHeight };
|
||
}
|
||
};
|
||
|
||
const onFrameTouchEnd = () => {
|
||
isMovingFrame.value = false;
|
||
isResizingFrame.value = false;
|
||
resizeDirection.value = '';
|
||
};
|
||
|
||
// 阻止事件冒泡,因为canvas的touchmove事件会在外部被阻止
|
||
// 这里需要处理裁剪框的触摸事件,不阻止冒泡会导致裁剪框无法拖动和缩放
|
||
const onFrameTouchStart = (e: any) => {
|
||
const { x, y } = getTouchPoint(e);
|
||
startX.value = x;
|
||
startY.value = y;
|
||
lastFrame.value = { ...cropFrame.value }; // 记录当前裁剪框状态
|
||
|
||
const targetDataset = e.target.dataset;
|
||
if (targetDataset && targetDataset.direction) {
|
||
isResizingFrame.value = true;
|
||
resizeDirection.value = targetDataset.direction;
|
||
} else {
|
||
isMovingFrame.value = true;
|
||
}
|
||
};
|
||
|
||
const confirmCrop = () => {
|
||
if (!ctx.value || !imgInfo.value || isProcessing.value) return;
|
||
|
||
isProcessing.value = true;
|
||
uni.showLoading({ title: '裁剪中...', mask: true });
|
||
|
||
const { path: imagePath, width: originalWidth, height: originalHeight } = imgInfo.value;
|
||
|
||
// 计算实际图片上的裁剪区域
|
||
// 图片显示区域与原始图片的比例
|
||
const scaleX = originalWidth / imageDisplay.value.width;
|
||
const scaleY = originalHeight / imageDisplay.value.height;
|
||
|
||
const sourceX = (cropFrame.value.x - imageDisplay.value.x) * scaleX;
|
||
const sourceY = (cropFrame.value.y - imageDisplay.value.y) * scaleY;
|
||
const sourceWidth = cropFrame.value.width * scaleX;
|
||
const sourceHeight = cropFrame.value.height * scaleY;
|
||
|
||
// 创建一个新的canvas用于裁剪,确保尺寸和输出尺寸匹配
|
||
const tempCanvasId = 'tempCropCanvas';
|
||
const tempCtx = uni.createCanvasContext(tempCanvasId, componentProxy);
|
||
|
||
// 设置裁剪输出尺寸
|
||
const outputWidthPx = rpxToPx(props.outputWidth);
|
||
const outputHeightPx = rpxToPx(props.outputHeight);
|
||
exportSize.value = { width: outputWidthPx, height: outputHeightPx };
|
||
|
||
// 绘制图片到临时canvas,进行裁剪和缩放
|
||
tempCtx.clearRect(0, 0, outputWidthPx, outputHeightPx);
|
||
tempCtx.drawImage(
|
||
imagePath,
|
||
sourceX,
|
||
sourceY,
|
||
sourceWidth,
|
||
sourceHeight,
|
||
0,
|
||
0,
|
||
outputWidthPx,
|
||
outputHeightPx
|
||
);
|
||
|
||
// 确保绘制完成
|
||
tempCtx.draw(false, () => {
|
||
let done = false;
|
||
const finish = (ok: boolean, payload?: any) => {
|
||
if (done) return;
|
||
done = true;
|
||
isProcessing.value = false;
|
||
uni.hideLoading();
|
||
if (!ok) return;
|
||
emit('confirm', payload);
|
||
emit('update:visible', false);
|
||
};
|
||
|
||
// 兜底:防止部分机型 canvasToTempFilePath 不回调导致“一直处理中”
|
||
const timer = setTimeout(() => {
|
||
uni.showToast({ title: '裁剪超时,请重试', icon: 'none' });
|
||
finish(false);
|
||
}, 4000);
|
||
|
||
// 延迟一段时间,确保canvas内容已经渲染
|
||
setTimeout(() => {
|
||
uni.canvasToTempFilePath({
|
||
canvasId: tempCanvasId,
|
||
x: 0,
|
||
y: 0,
|
||
width: outputWidthPx,
|
||
height: outputHeightPx,
|
||
destWidth: outputWidthPx,
|
||
destHeight: outputHeightPx,
|
||
success: (res) => {
|
||
clearTimeout(timer);
|
||
finish(true, res.tempFilePath);
|
||
},
|
||
fail: (err) => {
|
||
clearTimeout(timer);
|
||
uni.showToast({ title: '裁剪失败', icon: 'none' });
|
||
console.error('canvasToTempFilePath failed', err);
|
||
finish(false);
|
||
},
|
||
}, componentProxy);
|
||
}, 100); // 短暂延迟
|
||
});
|
||
};
|
||
|
||
const cancelCrop = () => {
|
||
emit('cancel');
|
||
emit('update:visible', false);
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.cropper-wrapper {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 9999;
|
||
background-color: #000;
|
||
display: none;
|
||
pointer-events: none;
|
||
overflow: hidden; // 隐藏超出部分
|
||
|
||
&.show {
|
||
display: block;
|
||
pointer-events: auto;
|
||
}
|
||
}
|
||
|
||
.display-layer {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.display-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.work-canvas {
|
||
position: fixed;
|
||
left: -99999px;
|
||
top: -99999px;
|
||
width: 1px;
|
||
height: 1px;
|
||
opacity: 0;
|
||
}
|
||
|
||
.temp-canvas {
|
||
position: fixed;
|
||
left: -99999px;
|
||
top: -99999px;
|
||
width: 1px;
|
||
height: 1px;
|
||
opacity: 0;
|
||
}
|
||
|
||
.crop-frame {
|
||
position: absolute;
|
||
border: 2px solid #00c0ff;
|
||
box-sizing: border-box;
|
||
cursor: grab;
|
||
touch-action: none; // 禁用浏览器默认触摸行为
|
||
z-index: 2;
|
||
}
|
||
|
||
.resize-handle {
|
||
position: absolute;
|
||
width: 32px;
|
||
height: 32px;
|
||
background-color: #00c0ff;
|
||
border: 2px solid #fff;
|
||
box-sizing: border-box;
|
||
opacity: 0.9;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.top-left {
|
||
top: -16px;
|
||
left: -16px;
|
||
cursor: nwse-resize;
|
||
}
|
||
.top-right {
|
||
top: -16px;
|
||
right: -16px;
|
||
cursor: nesw-resize;
|
||
}
|
||
.bottom-left {
|
||
bottom: -16px;
|
||
left: -16px;
|
||
cursor: nesw-resize;
|
||
}
|
||
.bottom-right {
|
||
bottom: -16px;
|
||
right: -16px;
|
||
cursor: nwse-resize;
|
||
}
|
||
|
||
.crop-controls {
|
||
position: absolute;
|
||
// left: 12px;
|
||
right: 12px;
|
||
z-index: 3;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 15px;
|
||
padding: 6px 10px;
|
||
box-sizing: border-box;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
border-radius: 12px;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.crop-controls button {
|
||
|
||
min-width: 30px;
|
||
padding: 5px 10px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: #007aff;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.crop-controls button:first-child {
|
||
background: #3c3c3c;
|
||
}
|
||
|
||
.crop-controls button[disabled] {
|
||
opacity: 0.6;
|
||
}
|
||
</style> |