test 图片裁剪

This commit is contained in:
icssoa
2025-07-30 18:42:46 +08:00
parent 66759392a4
commit dfc0538537
5 changed files with 861 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
---
description: Code template
globs: *.uvue
alwaysApply: false
---
## 页面模板代码
```uvue
<template>
<cl-page>
<view class="p-3"></view>
</cl-page>
</template>
<script lang="ts" setup>
</script>
```

View File

@@ -386,6 +386,12 @@
"style": {
"navigationBarTitleText": "Vibrate 震动"
}
},
{
"path": "other/cropper",
"style": {
"navigationBarTitleText": "Cropper 图片裁剪"
}
}
]
}

View File

@@ -0,0 +1,84 @@
<template>
<cl-page>
<view class="p-4">
<cl-text size="lg" bold class="mb-4">图片裁剪器演示</cl-text>
<view class="mb-4">
<cl-text size="sm" color="info" class="mb-2">操作说明:</cl-text>
<cl-text size="xs" color="placeholder" class="block mb-1"
>• 拖拽图片进行移动</cl-text
>
<cl-text size="xs" color="placeholder" class="block mb-1"
>• 双指缩放图片(围绕双指中心缩放)</cl-text
>
<cl-text size="xs" color="placeholder" class="block mb-1"
>• 拖拽裁剪框进行移动</cl-text
>
<cl-text size="xs" color="placeholder" class="block mb-1"
>• 拖拽裁剪框四角进行缩放</cl-text
>
<cl-text size="xs" color="placeholder" class="block mb-1"
>• 缩放时会显示辅助线</cl-text
>
<cl-text size="xs" color="warning" class="block"
>• 图片会自动保持不小于裁剪框大小</cl-text
>
</view>
<view class="flex justify-center">
<cl-cropper
:src="imageSrc"
:width="320"
:height="320"
:crop-width="200"
:crop-height="200"
:max-scale="3"
:min-scale="0.5"
@crop="onCrop"
@load="onImageLoad"
></cl-cropper>
</view>
<view class="mt-4 space-y-2">
<cl-button @tap="selectImage" block>选择图片</cl-button>
<cl-button @tap="useDefaultImage" type="light" block>使用默认图片</cl-button>
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { ref } from "vue";
const imageSrc = ref("/static/logo.png");
function selectImage() {
// 选择图片
uni.chooseImage({
count: 1,
sizeType: ["original", "compressed"],
sourceType: ["album", "camera"],
success: (res) => {
if (res.tempFilePaths.length > 0) {
imageSrc.value = res.tempFilePaths[0];
}
}
});
}
function useDefaultImage() {
imageSrc.value = "/static/logo.png";
}
function onCrop(result: any) {
console.log("裁剪结果:", result);
uni.showToast({
title: "裁剪完成",
icon: "success"
});
}
function onImageLoad(e: any) {
console.log("图片加载完成:", e);
}
</script>

View File

@@ -0,0 +1,727 @@
<template>
<view
class="cl-cropper"
:class="[
{
'is-disabled': disabled
},
pt.className
]"
:style="{
width: width + 'px',
height: height + 'px'
}"
>
<view class="cl-cropper__container" :class="[pt.inner?.className]">
<!-- 图片容器 -->
<view
class="cl-cropper__image-container"
:style="imageContainerStyle"
@touchstart="onImageTouchStart"
@touchmove="onImageTouchMove"
@touchend="onImageTouchEnd"
>
<image
class="cl-cropper__image"
:class="[pt.image?.className]"
:src="src"
:style="imageStyle"
mode="aspectFit"
@load="onImageLoad"
></image>
</view>
<!-- 裁剪框 -->
<view
class="cl-cropper__crop-box"
:class="[pt.cropBox?.className]"
:style="cropBoxStyle"
>
<view class="cl-cropper__crop-mask cl-cropper__crop-mask--top"></view>
<view class="cl-cropper__crop-mask cl-cropper__crop-mask--right"></view>
<view class="cl-cropper__crop-mask cl-cropper__crop-mask--bottom"></view>
<view class="cl-cropper__crop-mask cl-cropper__crop-mask--left"></view>
<!-- 裁剪区域 -->
<view
class="cl-cropper__crop-area"
:class="{ 'is-resizing': isResizing }"
@touchstart="onCropTouchStart"
@touchmove="onCropTouchMove"
@touchend="onCropTouchEnd"
>
<!-- 辅助线 -->
<view class="cl-cropper__guide-lines" v-if="showGuideLines">
<view class="cl-cropper__guide-line cl-cropper__guide-line--h1"></view>
<view class="cl-cropper__guide-line cl-cropper__guide-line--h2"></view>
<view class="cl-cropper__guide-line cl-cropper__guide-line--v1"></view>
<view class="cl-cropper__guide-line cl-cropper__guide-line--v2"></view>
</view>
<!-- 拖拽点 -->
<view
class="cl-cropper__drag-point cl-cropper__drag-point--tl"
@touchstart="onResizeTouchStart"
@touchmove="onResizeTouchMove"
@touchend="onResizeTouchEnd"
data-direction="tl"
></view>
<view
class="cl-cropper__drag-point cl-cropper__drag-point--tr"
@touchstart="onResizeTouchStart"
@touchmove="onResizeTouchMove"
@touchend="onResizeTouchEnd"
data-direction="tr"
></view>
<view
class="cl-cropper__drag-point cl-cropper__drag-point--bl"
@touchstart="onResizeTouchStart"
@touchmove="onResizeTouchMove"
@touchend="onResizeTouchEnd"
data-direction="bl"
></view>
<view
class="cl-cropper__drag-point cl-cropper__drag-point--br"
@touchstart="onResizeTouchStart"
@touchmove="onResizeTouchMove"
@touchend="onResizeTouchEnd"
data-direction="br"
></view>
</view>
</view>
<!-- 操作按钮 -->
<view class="cl-cropper__buttons" v-if="showButtons">
<cl-button
type="light"
size="small"
:pt="{ className: pt.button?.className }"
@tap="reset"
>
重置
</cl-button>
<cl-button
type="primary"
size="small"
:pt="{ className: pt.button?.className }"
@tap="crop"
>
裁剪
</cl-button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, reactive, onMounted, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import { parsePt, parseRpx } from "@/cool";
defineOptions({
name: "cl-cropper"
});
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 图片源
src: {
type: String,
default: ""
},
// 容器宽度
width: {
type: [String, Number] as PropType<string | number>,
default: 375
},
// 容器高度
height: {
type: [String, Number] as PropType<string | number>,
default: 375
},
// 裁剪框宽度
cropWidth: {
type: [String, Number] as PropType<string | number>,
default: 200
},
// 裁剪框高度
cropHeight: {
type: [String, Number] as PropType<string | number>,
default: 200
},
// 最大缩放比例
maxScale: {
type: Number,
default: 3
},
// 最小缩放比例
minScale: {
type: Number,
default: 0.5
},
// 是否显示操作按钮
showButtons: {
type: Boolean,
default: true
},
// 输出图片质量
quality: {
type: Number,
default: 0.9
},
// 输出图片格式
format: {
type: String as PropType<"jpg" | "png">,
default: "jpg"
},
// 是否禁用
disabled: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits(["crop", "load", "error"]);
// 透传样式类型
type PassThrough = {
className?: string;
inner?: PassThroughProps;
image?: PassThroughProps;
cropBox?: PassThroughProps;
button?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 图片信息
const imageInfo = reactive({
width: 0,
height: 0,
loaded: false
});
// 图片变换状态
const imageTransform = reactive({
scale: 1,
translateX: 0,
translateY: 0
});
// 裁剪框状态
const cropBox = reactive({
x: 0,
y: 0,
width: 200,
height: 200
});
// 触摸状态
const touchState = reactive({
startX: 0,
startY: 0,
startDistance: 0,
startScale: 1,
startTranslateX: 0,
startTranslateY: 0,
touching: false,
mode: "", // image, crop, resize
resizeDirection: "" // tl, tr, bl, br
});
// 缩放状态
const isResizing = ref(false);
const showGuideLines = ref(false);
// 计算图片容器样式
const imageContainerStyle = computed(() => {
return {
width: "100%",
height: "100%",
overflow: "hidden"
};
});
// 计算图片样式
const imageStyle = computed(() => {
return {
transform: `translate3d(${imageTransform.translateX}px, ${imageTransform.translateY}px, 0) scale(${imageTransform.scale})`,
transformOrigin: "center center",
transition: touchState.touching ? "none" : "transform 0.3s ease"
};
});
// 计算裁剪框样式
const cropBoxStyle = computed(() => {
return {
left: `${cropBox.x}px`,
top: `${cropBox.y}px`,
width: `${cropBox.width}px`,
height: `${cropBox.height}px`
};
});
// 图片加载完成
function onImageLoad(e: any) {
imageInfo.width = e.detail.width;
imageInfo.height = e.detail.height;
imageInfo.loaded = true;
// 初始化裁剪框位置
initCropBox();
emit("load", e);
}
// 计算图片最小缩放比例(确保图片不小于裁剪框)
function calculateMinScale() {
if (!imageInfo.loaded || !imageInfo.width || !imageInfo.height) {
return props.minScale;
}
const containerWidth = parseFloat(parseRpx(props.width!).replace("px", ""));
const containerHeight = parseFloat(parseRpx(props.height!).replace("px", ""));
// 计算图片在容器中的显示尺寸(保持宽高比)
const imageAspectRatio = imageInfo.width / imageInfo.height;
const containerAspectRatio = containerWidth / containerHeight;
let displayWidth: number;
let displayHeight: number;
if (imageAspectRatio > containerAspectRatio) {
// 图片较宽,以容器宽度为准
displayWidth = containerWidth;
displayHeight = containerWidth / imageAspectRatio;
} else {
// 图片较高,以容器高度为准
displayHeight = containerHeight;
displayWidth = containerHeight * imageAspectRatio;
}
// 计算使图片能够覆盖裁剪框的最小缩放比例
const minScaleX = cropBox.width / displayWidth;
const minScaleY = cropBox.height / displayHeight;
const calculatedMinScale = Math.max(minScaleX, minScaleY);
// 确保不低于用户设置的最小缩放比例
return Math.max(props.minScale, calculatedMinScale);
}
// 初始化裁剪框
function initCropBox() {
const containerWidth = parseFloat(parseRpx(props.width!).replace("px", ""));
const containerHeight = parseFloat(parseRpx(props.height!).replace("px", ""));
cropBox.width = parseFloat(parseRpx(props.cropWidth!).replace("px", ""));
cropBox.height = parseFloat(parseRpx(props.cropHeight!).replace("px", ""));
cropBox.x = (containerWidth - cropBox.width) / 2;
cropBox.y = (containerHeight - cropBox.height) / 2;
// 图片加载完成后,确保当前缩放比例不小于最小要求
if (imageInfo.loaded) {
const minScale = calculateMinScale();
if (imageTransform.scale < minScale) {
imageTransform.scale = minScale;
}
}
}
// 图片触摸开始
function onImageTouchStart(e: TouchEvent) {
if (props.disabled || !imageInfo.loaded) return;
touchState.touching = true;
touchState.mode = "image";
if (e.touches != null && e.touches.length == 1) {
// 单指拖拽
touchState.startX = e.touches[0].clientX;
touchState.startY = e.touches[0].clientY;
touchState.startTranslateX = imageTransform.translateX;
touchState.startTranslateY = imageTransform.translateY;
} else if (e.touches != null && e.touches.length == 2) {
// 双指缩放
const touch1 = e.touches[0];
const touch2 = e.touches[1];
// 计算两指间距离
touchState.startDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
touchState.startScale = imageTransform.scale;
// 记录缩放中心点(两指中心)
touchState.startX = (touch1.clientX + touch2.clientX) / 2;
touchState.startY = (touch1.clientY + touch2.clientY) / 2;
touchState.startTranslateX = imageTransform.translateX;
touchState.startTranslateY = imageTransform.translateY;
}
}
// 图片触摸移动
function onImageTouchMove(e: TouchEvent) {
if (props.disabled || !touchState.touching || touchState.mode != "image") return;
e.preventDefault();
if (e.touches != null && e.touches.length == 1) {
// 单指拖拽
const deltaX = e.touches[0].clientX - touchState.startX;
const deltaY = e.touches[0].clientY - touchState.startY;
imageTransform.translateX = touchState.startTranslateX + deltaX;
imageTransform.translateY = touchState.startTranslateY + deltaY;
} else if (e.touches != null && e.touches.length == 2) {
// 双指缩放
const touch1 = e.touches[0];
const touch2 = e.touches[1];
// 计算当前两指间距离
const distance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
// 计算缩放比例
const scale = (distance / touchState.startDistance) * touchState.startScale;
// 使用动态计算的最小缩放比例
const minScale = calculateMinScale();
const newScale = Math.max(minScale, Math.min(props.maxScale, scale));
// 计算当前缩放中心点
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
// 计算缩放中心相对于容器的偏移
const containerWidth = parseFloat(parseRpx(props.width!).replace("px", ""));
const containerHeight = parseFloat(parseRpx(props.height!).replace("px", ""));
const containerCenterX = containerWidth / 2;
const containerCenterY = containerHeight / 2;
// 缩放时调整位移,使缩放围绕双指中心进行
const scaleDelta = newScale - touchState.startScale;
const offsetX = (centerX - containerCenterX - touchState.startX + containerCenterX) * scaleDelta / touchState.startScale;
const offsetY = (centerY - containerCenterY - touchState.startY + containerCenterY) * scaleDelta / touchState.startScale;
imageTransform.scale = newScale;
imageTransform.translateX = touchState.startTranslateX - offsetX;
imageTransform.translateY = touchState.startTranslateY - offsetY;
}
}
// 图片触摸结束
function onImageTouchEnd(e: TouchEvent) {
touchState.touching = false;
touchState.mode = "";
}
// 裁剪框触摸开始
function onCropTouchStart(e: TouchEvent) {
if (props.disabled) return;
e.stopPropagation();
touchState.touching = true;
touchState.mode = "crop";
if (e.touches != null && e.touches.length == 1) {
touchState.startX = e.touches[0].clientX;
touchState.startY = e.touches[0].clientY;
}
}
// 裁剪框触摸移动
function onCropTouchMove(e: TouchEvent) {
if (props.disabled || !touchState.touching || touchState.mode != "crop") return;
e.preventDefault();
e.stopPropagation();
if (e.touches != null && e.touches.length == 1) {
const deltaX = e.touches[0].clientX - touchState.startX;
const deltaY = e.touches[0].clientY - touchState.startY;
const containerWidth = parseFloat(parseRpx(props.width!).replace("px", ""));
const containerHeight = parseFloat(parseRpx(props.height!).replace("px", ""));
// 限制裁剪框在容器内
cropBox.x = Math.max(0, Math.min(containerWidth - cropBox.width, cropBox.x + deltaX));
cropBox.y = Math.max(0, Math.min(containerHeight - cropBox.height, cropBox.y + deltaY));
touchState.startX = e.touches[0].clientX;
touchState.startY = e.touches[0].clientY;
}
}
// 裁剪框触摸结束
function onCropTouchEnd(e: TouchEvent) {
touchState.touching = false;
touchState.mode = "";
}
// 裁剪框缩放开始
function onResizeTouchStart(e: TouchEvent) {
if (props.disabled) return;
e.stopPropagation();
touchState.touching = true;
touchState.mode = "resize";
isResizing.value = true;
showGuideLines.value = true;
// 从 data-direction 属性获取缩放方向
const target = e.target as HTMLElement;
touchState.resizeDirection = target.getAttribute("data-direction") || "";
if (e.touches != null && e.touches.length == 1) {
touchState.startX = e.touches[0].clientX;
touchState.startY = e.touches[0].clientY;
}
}
// 裁剪框缩放移动
function onResizeTouchMove(e: TouchEvent) {
if (props.disabled || !touchState.touching || touchState.mode != "resize") return;
e.preventDefault();
e.stopPropagation();
if (e.touches != null && e.touches.length == 1) {
const deltaX = e.touches[0].clientX - touchState.startX;
const deltaY = e.touches[0].clientY - touchState.startY;
const containerWidth = parseFloat(parseRpx(props.width!).replace("px", ""));
const containerHeight = parseFloat(parseRpx(props.height!).replace("px", ""));
// 最小裁剪框尺寸
const minSize = 50;
let newX = cropBox.x;
let newY = cropBox.y;
let newWidth = cropBox.width;
let newHeight = cropBox.height;
// 根据拖拽方向调整裁剪框
switch (touchState.resizeDirection) {
case "tl": // 左上角
newX = Math.max(0, cropBox.x + deltaX);
newY = Math.max(0, cropBox.y + deltaY);
newWidth = cropBox.width - deltaX;
newHeight = cropBox.height - deltaY;
break;
case "tr": // 右上角
newY = Math.max(0, cropBox.y + deltaY);
newWidth = cropBox.width + deltaX;
newHeight = cropBox.height - deltaY;
break;
case "bl": // 左下角
newX = Math.max(0, cropBox.x + deltaX);
newWidth = cropBox.width - deltaX;
newHeight = cropBox.height + deltaY;
break;
case "br": // 右下角
newWidth = cropBox.width + deltaX;
newHeight = cropBox.height + deltaY;
break;
}
// 确保尺寸不小于最小值且不超出容器
if (newWidth >= minSize && newHeight >= minSize) {
// 检查是否超出容器边界
if (newX >= 0 && newY >= 0 &&
newX + newWidth <= containerWidth &&
newY + newHeight <= containerHeight) {
cropBox.x = newX;
cropBox.y = newY;
cropBox.width = newWidth;
cropBox.height = newHeight;
// 当裁剪框大小改变时,检查图片是否需要放大
const minScale = calculateMinScale();
if (imageTransform.scale < minScale) {
imageTransform.scale = minScale;
}
// 更新起始位置
touchState.startX = e.touches[0].clientX;
touchState.startY = e.touches[0].clientY;
}
}
}
}
// 裁剪框缩放结束
function onResizeTouchEnd(e: TouchEvent) {
touchState.touching = false;
touchState.mode = "";
touchState.resizeDirection = "";
isResizing.value = false;
// 延迟隐藏辅助线,给用户一点时间看到最终位置
setTimeout(() => {
showGuideLines.value = false;
}, 500);
}
// 重置
function reset() {
// 重置位移
imageTransform.translateX = 0;
imageTransform.translateY = 0;
// 重置裁剪框
initCropBox();
// 设置合适的初始缩放比例(确保图片能覆盖裁剪框)
if (imageInfo.loaded) {
const minScale = calculateMinScale();
imageTransform.scale = Math.max(1, minScale);
} else {
imageTransform.scale = 1;
}
}
// 执行裁剪
function crop() {
if (!imageInfo.loaded) {
emit("error", "图片未加载完成");
return;
}
}
// 初始化
onMounted(() => {
if (props.src != "") {
initCropBox();
}
});
</script>
<style lang="scss" scoped>
.cl-cropper {
@apply relative overflow-hidden bg-surface-100 rounded-xl;
&.is-disabled {
@apply opacity-50;
}
&__container {
@apply relative w-full h-full;
}
&__image-container {
@apply absolute top-0 left-0 flex items-center justify-center;
}
&__image {
@apply max-w-full max-h-full;
}
&__crop-box {
@apply absolute;
}
&__crop-mask {
@apply absolute bg-black opacity-50;
&--top {
@apply top-0 left-0 w-full;
height: var(--crop-top);
}
&--right {
@apply top-0 right-0 h-full;
width: var(--crop-right);
}
&--bottom {
@apply bottom-0 left-0 w-full;
height: var(--crop-bottom);
}
&--left {
@apply top-0 left-0 h-full;
width: var(--crop-left);
}
}
&__crop-area {
@apply relative w-full h-full border-2 border-white border-solid transition-all duration-300;
&.is-resizing {
@apply border-primary-500;
}
}
&__guide-lines {
@apply absolute top-0 left-0 w-full h-full pointer-events-none;
}
&__guide-line {
@apply absolute bg-white opacity-70;
&--h1 {
@apply top-1/3 left-0 w-full h-px;
}
&--h2 {
@apply top-2/3 left-0 w-full h-px;
}
&--v1 {
@apply left-1/3 top-0 w-px h-full;
}
&--v2 {
@apply left-2/3 top-0 w-px h-full;
}
}
&__drag-point {
@apply absolute w-3 h-3 bg-white border border-surface-300 border-solid cursor-move transition-all duration-200;
&:hover {
@apply scale-125 border-primary-500;
}
&--tl {
@apply -top-1 -left-1;
cursor: nw-resize;
}
&--tr {
@apply -top-1 -right-1;
cursor: ne-resize;
}
&--bl {
@apply -bottom-1 -left-1;
cursor: sw-resize;
}
&--br {
@apply -bottom-1 -right-1;
cursor: se-resize;
}
// 缩放时高亮显示
.is-resizing & {
@apply scale-125 border-primary-500 shadow-md;
}
}
&__buttons {
@apply absolute bottom-4 left-0 right-0 flex justify-center space-x-4;
}
}
</style>

View File

@@ -0,0 +1,25 @@
import type { PassThroughProps } from "../../types";
export type ClCropperPassThrough = {
className?: string;
inner?: PassThroughProps;
image?: PassThroughProps;
cropBox?: PassThroughProps;
button?: PassThroughProps;
};
export type ClCropperProps = {
className?: string;
pt?: ClCropperPassThrough;
src?: string;
width?: string | number;
height?: string | number;
cropWidth?: string | number;
cropHeight?: string | number;
maxScale?: number;
minScale?: number;
showButtons?: boolean;
quality?: number;
format?: "jpg" | "png";
disabled?: boolean;
};