feat: ImgCropper 图片剪裁支持设置裁剪框宽高比 (#973)

 Closes: #920
This commit is contained in:
不如摸鱼去 2025-03-25 23:41:32 +08:00 committed by GitHub
parent b85237643c
commit 5a3f85df6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 311 additions and 30 deletions

View File

@ -70,6 +70,7 @@ function handleCancel(event) {
| confirm-button-text | 确认按钮文案 | string | - | 完成 | - | | confirm-button-text | 确认按钮文案 | string | - | 完成 | - |
| quality | 生成的图片质量 [wx.canvasToTempFilePath属性介绍](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html#%E5%8F%82%E6%95%B0) | number | 0/1 | 1 | - | | quality | 生成的图片质量 [wx.canvasToTempFilePath属性介绍](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html#%E5%8F%82%E6%95%B0) | number | 0/1 | 1 | - |
| file-type | 目标文件的类型,[wx.canvasToTempFilePath属性介绍](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html#%E5%8F%82%E6%95%B0) | string | - | png | - | | file-type | 目标文件的类型,[wx.canvasToTempFilePath属性介绍](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html#%E5%8F%82%E6%95%B0) | string | - | png | - |
| aspect-ratio | 裁剪框宽高比,格式为 width:height | string | - | 1:1 | $LOWEST_VERSION$ |
## Events ## Events
@ -94,3 +95,79 @@ function handleCancel(event) {
| 类名 | 说明 | 最低版本 | | 类名 | 说明 | 最低版本 |
|-----|------|--------| |-----|------|--------|
| custom-class | 根节点样式 | - | | custom-class | 根节点样式 | - |
## 基本用法
```html
<!-- 设置3:2的裁剪框 -->
<wd-img-cropper
v-model="show"
:img-src="src"
aspect-ratio="3:2"
@confirm="handleConfirm"
@cancel="handleCancel"
>
</wd-img-cropper>
```
## 裁剪后上传
结合 `useUpload` 可以实现裁剪完成后自动上传图片的功能。
```html
<wd-img-cropper
v-model="show"
:img-src="src"
@confirm="handleConfirmUpload"
@cancel="handleCancel"
>
</wd-img-cropper>
```
```typescript
import { ref } from 'vue'
import { useUpload, useToast } from '@/uni_modules/wot-design-uni'
import { type UploadFileItem } from '@/uni_modules/wot-design-uni/components/wd-upload/types'
const { startUpload, UPLOAD_STATUS } = useUpload()
const { show: showToast } = useToast()
const show = ref(false)
const src = ref('')
const imgSrc = ref('')
async function handleConfirmUpload(event) {
const { tempFilePath } = event
// 构建上传文件对象
const file: UploadFileItem = {
url: tempFilePath,
status: UPLOAD_STATUS.PENDING,
percent: 0,
uid: new Date().getTime()
}
try {
// 开始上传
await startUpload(file, {
action: 'https://your-upload-url',
onSuccess() {
imgSrc.value = tempFilePath
showToast({
msg: '上传成功'
})
},
onError() {
showToast({
msg: '上传失败'
})
},
onProgress(res) {
console.log('上传进度:', res.progress)
}
})
} catch (error) {
console.error('上传失败:', error)
}
}
```

View File

@ -1,7 +1,7 @@
<!-- <!--
* @Author: weisheng * @Author: weisheng
* @Date: 2023-09-20 11:10:41 * @Date: 2023-09-20 11:10:41
* @LastEditTime: 2024-06-06 21:45:41 * @LastEditTime: 2025-03-25 22:59:47
* @LastEditors: weisheng * @LastEditors: weisheng
* @Description: * @Description:
* @FilePath: /wot-design-uni/src/pages/imgCropper/Index.vue * @FilePath: /wot-design-uni/src/pages/imgCropper/Index.vue
@ -29,15 +29,79 @@
<view style="font-size: 14px">点击上传头像</view> <view style="font-size: 14px">点击上传头像</view>
</view> </view>
</demo-block> </demo-block>
<demo-block title="自定义裁剪比例" style="text-align: center">
<view class="profile-grid">
<view v-for="(ratio, index) in ['1:1', '3:2', '16:9']" :key="index" class="profile-item">
<wd-img-cropper
v-model="showCustom[index]"
:img-src="srcCustom[index]"
:aspect-ratio="ratio"
@confirm="handleCustomConfirm(index, $event)"
@cancel="handleCustomCancel"
></wd-img-cropper>
<view v-if="!imgSrcCustom[index]" class="img" @click="uploadCustom(index)">
<wd-icon name="fill-camera" custom-class="img-icon"></wd-icon>
</view>
<wd-img
v-if="imgSrcCustom[index]"
:width="ratio === '1:1' ? '200px' : '300px'"
:height="getHeight(ratio)"
:src="imgSrcCustom[index]"
mode="aspectFit"
custom-class="profile-img"
@click="uploadCustom(index)"
/>
<view style="font-size: 14px">{{ ratio }} 比例裁剪</view>
</view>
</view>
</demo-block>
<demo-block title="裁剪后上传" style="text-align: center">
<wd-img-cropper v-model="showUpload" :img-src="srcUpload" @confirm="handleConfirmUpload" @cancel="handleCancel"></wd-img-cropper>
<view class="profile">
<view v-if="!imgSrcUpload" class="img" @click="uploadWithCrop">
<wd-icon name="fill-camera" custom-class="img-icon"></wd-icon>
</view>
<wd-img
v-if="imgSrcUpload"
round
width="200px"
height="200px"
:src="imgSrcUpload"
mode="aspectFit"
custom-class="profile-img"
@click="uploadWithCrop"
/>
<view style="font-size: 14px">点击上传裁剪后的头像</view>
</view>
</demo-block>
</page-wraper> </page-wraper>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useUpload, useToast } from '@/uni_modules/wot-design-uni'
import { type UploadFileItem } from '@/uni_modules/wot-design-uni/components/wd-upload/types'
const { startUpload, UPLOAD_STATUS } = useUpload()
const { show: showToast } = useToast()
const src = ref<string>('') const src = ref<string>('')
const imgSrc = ref<string>('') const imgSrc = ref<string>('')
const show = ref<boolean>(false) const show = ref<boolean>(false)
//
const showCustom = ref<boolean[]>([false, false, false])
const srcCustom = ref<string[]>(['', '', ''])
const imgSrcCustom = ref<string[]>(['', '', ''])
//
const showUpload = ref<boolean>(false)
const srcUpload = ref<string>('')
const imgSrcUpload = ref<string>('')
function upload() { function upload() {
uni.chooseImage({ uni.chooseImage({
count: 1, count: 1,
@ -48,20 +112,96 @@ function upload() {
} }
}) })
} }
function uploadCustom(index: number) {
uni.chooseImage({
count: 1,
success: (res) => {
const tempFilePaths = res.tempFilePaths[0]
srcCustom.value[index] = tempFilePaths
showCustom.value[index] = true
}
})
}
function uploadWithCrop() {
uni.chooseImage({
count: 1,
success: (res) => {
srcUpload.value = res.tempFilePaths[0]
showUpload.value = true
}
})
}
function handleConfirm(event: any) { function handleConfirm(event: any) {
const { tempFilePath } = event const { tempFilePath } = event
imgSrc.value = tempFilePath imgSrc.value = tempFilePath
} }
function handleCustomConfirm(index: number, event: any) {
const { tempFilePath } = event
imgSrcCustom.value[index] = tempFilePath
}
async function handleConfirmUpload(event: any) {
const { tempFilePath } = event
//
const file: UploadFileItem = {
url: tempFilePath,
status: UPLOAD_STATUS.PENDING,
percent: 0,
uid: new Date().getTime()
}
try {
//
await startUpload(file, {
action: 'https://mockapi.eolink.com/zhTuw2P8c29bc981a741931bdd86eb04dc1e8fd64865cb5/upload', //
onSuccess() {
imgSrcUpload.value = tempFilePath
showToast({
msg: '上传成功'
})
},
onError() {
showToast({
msg: '上传失败'
})
},
onProgress(res) {
console.log('上传进度:', res.progress)
}
})
} catch (error) {
console.error('上传失败:', error)
}
}
function imgLoaderror(res: any) { function imgLoaderror(res: any) {
console.log('加载失败', res) console.log('加载失败', res)
} }
function imgLoaded(res: any) { function imgLoaded(res: any) {
console.log('加载成功', res) console.log('加载成功', res)
} }
function handleCancel(event: any) { function handleCancel(event: any) {
console.log('取消', event) console.log('取消', event)
} }
function handleCustomCancel(event: any) {
console.log('取消', event)
}
function getHeight(ratio: string): string {
const [w, h] = ratio.split(':').map(Number)
if (ratio === '1:1') return '200px'
return `${(300 * h) / w}px`
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.wot-theme-dark { .wot-theme-dark {
:deep(.profile-img) { :deep(.profile-img) {
@ -98,4 +238,37 @@ function handleCancel(event: any) {
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.profile-grid {
display: flex;
flex-direction: column;
gap: 40px;
align-items: center;
padding: 20px;
}
.profile-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.profile-item .img {
width: 300px;
height: 169px; // 16:9
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.04);
position: relative;
}
.profile-item:first-child .img {
width: 200px;
height: 200px;
border-radius: 50%;
}
.profile-item:nth-child(2) .img {
height: 200px;
}
</style> </style>

View File

@ -1,7 +1,7 @@
/* /*
* @Author: weisheng * @Author: weisheng
* @Date: 2024-06-03 23:43:43 * @Date: 2024-06-03 23:43:43
* @LastEditTime: 2024-06-06 21:40:53 * @LastEditTime: 2025-03-25 17:28:13
* @LastEditors: weisheng * @LastEditors: weisheng
* @Description: * @Description:
* @FilePath: /wot-design-uni/src/uni_modules/wot-design-uni/components/wd-img-cropper/types.ts * @FilePath: /wot-design-uni/src/uni_modules/wot-design-uni/components/wd-img-cropper/types.ts
@ -57,7 +57,11 @@ export const imgCropperProps = {
/** /**
* *
*/ */
maxScale: makeNumberProp(3) maxScale: makeNumberProp(3),
/**
* width:height
*/
aspectRatio: makeStringProp('1:1')
} }
export type ImgCropperProps = ExtractPropTypes<typeof imgCropperProps> export type ImgCropperProps = ExtractPropTypes<typeof imgCropperProps>

View File

@ -7,11 +7,11 @@
<view class="wd-img-cropper__cut"> <view class="wd-img-cropper__cut">
<!-- 上方阴影块 --> <!-- 上方阴影块 -->
<view :class="`wd-img-cropper__cut--top ${IS_TOUCH_END ? '' : 'is-hightlight'}`" :style="`height: ${cutTop}px;`"></view> <view :class="`wd-img-cropper__cut--top ${IS_TOUCH_END ? '' : 'is-hightlight'}`" :style="`height: ${cutTop}px;`"></view>
<view class="wd-img-cropper__cut--middle"> <view class="wd-img-cropper__cut--middle" :style="`height: ${cutHeight}px;`">
<!-- 左侧阴影块 --> <!-- 左侧阴影块 -->
<view <view
:class="`wd-img-cropper__cut--left ${IS_TOUCH_END ? '' : 'is-hightlight'}`" :class="`wd-img-cropper__cut--left ${IS_TOUCH_END ? '' : 'is-hightlight'}`"
:style="`width: ${cutLeft}px; height: ${cutWidth}px;`" :style="`width: ${cutLeft}px; height: ${cutHeight}px;`"
></view> ></view>
<!-- 裁剪框 --> <!-- 裁剪框 -->
<view class="wd-img-cropper__cut--body" :style="`width: ${cutWidth}px; height: ${cutHeight}px;`"> <view class="wd-img-cropper__cut--body" :style="`width: ${cutWidth}px; height: ${cutHeight}px;`">
@ -163,14 +163,21 @@ watch(
INIT_IMGWIDTH = props.imgWidth INIT_IMGWIDTH = props.imgWidth
INIT_IMGHEIGHT = props.imgHeight INIT_IMGHEIGHT = props.imgHeight
info.value = uni.getSystemInfoSync() info.value = uni.getSystemInfoSync()
const tempCutSize = info.value.windowWidth - offset.value * 2
cutWidth.value = tempCutSize // aspectRatio
cutHeight.value = tempCutSize const [widthRatio, heightRatio] = props.aspectRatio.split(':').map(Number)
cutTop.value = (info.value.windowHeight * TOP_PERCENT - tempCutSize) / 2 const tempCutWidth = info.value.windowWidth - offset.value * 2
const tempCutHeight = (tempCutWidth * heightRatio) / widthRatio
cutWidth.value = tempCutWidth
cutHeight.value = tempCutHeight
cutTop.value = (info.value.windowHeight * TOP_PERCENT - tempCutHeight) / 2
cutLeft.value = offset.value cutLeft.value = offset.value
canvasScale.value = props.exportScale canvasScale.value = props.exportScale
canvasHeight.value = tempCutSize canvasHeight.value = tempCutHeight
canvasWidth.value = tempCutSize canvasWidth.value = tempCutWidth
// //
initImageSize() initImageSize()
// canvas // canvas
@ -268,7 +275,23 @@ function setRoate(angle: number) {
if (!angle || props.disabledRotate) return if (!angle || props.disabledRotate) return
revertIsAnimation(true) revertIsAnimation(true)
imgAngle.value = angle imgAngle.value = angle
//
//
let tempPicWidth = picWidth.value
let tempPicHeight = picHeight.value
//
if ((angle / 90) % 2) {
tempPicWidth = picHeight.value
tempPicHeight = picWidth.value
}
//
const widthRatio = cutWidth.value / tempPicWidth
const heightRatio = cutHeight.value / tempPicHeight
imgScale.value = Math.max(widthRatio, heightRatio)
//
detectImgPosIsEdge() detectImgPosIsEdge()
} }
@ -306,39 +329,43 @@ function loadImg() {
} }
/** /**
* 设置图片尺寸使其有一边小于裁剪框尺寸 * 设置图片尺寸使其短边完全显示并填满裁剪框
* 1图片宽或高 小于裁剪框自动放大至一边与高平齐
* 2图片宽或高 大于裁剪框自动缩小至一边与高平齐
*/ */
function computeImgSize() { function computeImgSize() {
let tempPicWidth: number = picWidth.value let tempPicWidth: number = picWidth.value
let tempPicHeight: number = picHeight.value let tempPicHeight: number = picHeight.value
if (!INIT_IMGHEIGHT && !INIT_IMGWIDTH) { if (!INIT_IMGHEIGHT && !INIT_IMGWIDTH) {
// //
tempPicWidth = imgInfo.value!.width const imgRatio = imgInfo.value!.width / imgInfo.value!.height
tempPicHeight = imgInfo.value!.height const cropRatio = cutWidth.value / cutHeight.value
/**
* a = imgWidth; b = imgHeight; x = cutWidth; y = cutHeight if (imgRatio > cropRatio) {
* 共有三种宽高比1a/b > x/y 2a/b < x/y 3a/b = x/y //
* 1已知 b = y => a = a/b*y
* 2已知 a = x => b = b/a*x
* 3可用上方任意公式
*/
if (picWidth.value / picHeight.value > cutWidth.value / cutHeight.value) {
tempPicHeight = cutHeight.value tempPicHeight = cutHeight.value
tempPicWidth = (imgInfo.value!.width / imgInfo.value!.height) * cutHeight.value tempPicWidth = tempPicHeight * imgRatio
} else { } else {
//
tempPicWidth = cutWidth.value tempPicWidth = cutWidth.value
tempPicHeight = (imgInfo.value!.height / imgInfo.value!.width) * cutWidth.value tempPicHeight = tempPicWidth / imgRatio
} }
} else if (INIT_IMGHEIGHT && !INIT_IMGWIDTH) { } else if (INIT_IMGHEIGHT && !INIT_IMGWIDTH) {
tempPicWidth = (imgInfo.value!.width / imgInfo.value!.height) * Number(INIT_IMGHEIGHT) tempPicHeight = Number(INIT_IMGHEIGHT)
tempPicWidth = (imgInfo.value!.width / imgInfo.value!.height) * tempPicHeight
} else if ((!INIT_IMGHEIGHT && INIT_IMGWIDTH) || (INIT_IMGHEIGHT && INIT_IMGWIDTH)) { } else if ((!INIT_IMGHEIGHT && INIT_IMGWIDTH) || (INIT_IMGHEIGHT && INIT_IMGWIDTH)) {
tempPicHeight = (imgInfo.value!.height / imgInfo.value!.width) * Number(INIT_IMGWIDTH) tempPicWidth = Number(INIT_IMGWIDTH)
tempPicHeight = (imgInfo.value!.height / imgInfo.value!.width) * tempPicWidth
} }
//
const widthRatio = cutWidth.value / tempPicWidth
const heightRatio = cutHeight.value / tempPicHeight
const scale = Math.max(widthRatio, heightRatio)
picWidth.value = tempPicWidth picWidth.value = tempPicWidth
picHeight.value = tempPicHeight picHeight.value = tempPicHeight
//
imgScale.value = scale
} }
/** /**