2026-02-05 12:47:24 +08:00

600 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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