11 changed files with 990 additions and 6 deletions
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
{ |
||||
"navigationBarTitleText": "图片裁剪", |
||||
"disableScroll": true, |
||||
"usingComponents": { |
||||
"van-button": "@vant/weapp/button/index", |
||||
"van-icon": "@vant/weapp/icon/index", |
||||
"van-toast": "@vant/weapp/toast/index" |
||||
} |
||||
} |
||||
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
page { |
||||
background-color: #1a1a1a; |
||||
height: 100vh; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.container { |
||||
height: 100vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
padding: 30rpx; |
||||
} |
||||
|
||||
.crop-wrapper { |
||||
flex: 1; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.crop-inner { |
||||
position: relative; |
||||
} |
||||
|
||||
.crop-area { |
||||
position: relative; |
||||
overflow: hidden; |
||||
background-color: #000; |
||||
} |
||||
|
||||
.source-image { |
||||
transform-origin: 0 0; |
||||
} |
||||
|
||||
.crop-frame { |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
pointer-events: none; |
||||
border: 2rpx solid rgba(255, 255, 255, 0.5); |
||||
box-shadow: 0 0 0 9999rpx rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
.frame-corner { |
||||
position: absolute; |
||||
width: 40rpx; |
||||
height: 40rpx; |
||||
border: 4rpx solid #fff; |
||||
} |
||||
|
||||
.left-top { |
||||
top: 0; |
||||
left: 0; |
||||
border-right: none; |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.right-top { |
||||
top: 0; |
||||
right: 0; |
||||
border-left: none; |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.left-bottom { |
||||
bottom: 0; |
||||
left: 0; |
||||
border-right: none; |
||||
border-top: none; |
||||
} |
||||
|
||||
.right-bottom { |
||||
bottom: 0; |
||||
right: 0; |
||||
border-left: none; |
||||
border-top: none; |
||||
} |
||||
|
||||
.tip-text { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
gap: 8rpx; |
||||
padding: 30rpx 0; |
||||
font-size: 24rpx; |
||||
color: #999; |
||||
} |
||||
|
||||
.action-buttons { |
||||
padding: 20rpx 0; |
||||
} |
||||
|
||||
.crop-canvas { |
||||
position: fixed; |
||||
left: -9999px; |
||||
top: -9999px; |
||||
} |
||||
@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
|
||||
import Toast from '@vant/weapp/toast/toast' |
||||
|
||||
interface TouchPoint { |
||||
x: number |
||||
y: number |
||||
} |
||||
|
||||
Page({ |
||||
data: { |
||||
imageSrc: '', |
||||
imgWidth: 0, |
||||
imgHeight: 0, |
||||
imgX: 0, |
||||
imgY: 0, |
||||
scale: 1, |
||||
minScale: 0.5, |
||||
maxScale: 4, |
||||
cropSize: 280, |
||||
originScale: 1, |
||||
lastTouches: [] as TouchPoint[], |
||||
}, |
||||
|
||||
onLoad(options: { imageSrc?: string }) { |
||||
const systemInfo = wx.getSystemInfoSync() |
||||
const cropSize = Math.min(systemInfo.windowWidth - 60, 350) |
||||
this.setData({ cropSize }) |
||||
|
||||
if (options.imageSrc) { |
||||
this.setData({ imageSrc: decodeURIComponent(options.imageSrc) }) |
||||
} |
||||
else { |
||||
this.chooseImage() |
||||
} |
||||
}, |
||||
|
||||
chooseImage() { |
||||
wx.chooseMedia({ |
||||
count: 1, |
||||
mediaType: ['image'], |
||||
sourceType: ['album', 'camera'], |
||||
success: (res) => { |
||||
if (res.tempFiles.length > 0) { |
||||
this.initImage(res.tempFiles[0].tempFilePath) |
||||
} |
||||
}, |
||||
fail: () => { |
||||
Toast('请先选择图片') |
||||
wx.navigateBack() |
||||
}, |
||||
}) |
||||
}, |
||||
|
||||
initImage(src: string) { |
||||
wx.getImageInfo({ |
||||
src, |
||||
success: (res) => { |
||||
const { width, height } = res |
||||
const { cropSize } = this.data |
||||
|
||||
const scaleX = cropSize / width |
||||
const scaleY = cropSize / height |
||||
const scale = Math.max(scaleX, scaleY) |
||||
|
||||
const imgWidth = width * scale |
||||
const imgHeight = height * scale |
||||
const imgX = (cropSize - imgWidth) / 2 |
||||
const imgY = (cropSize - imgHeight) / 2 |
||||
|
||||
this.setData({ |
||||
imageSrc: src, |
||||
imgWidth, |
||||
imgHeight, |
||||
imgX, |
||||
imgY, |
||||
scale: 1, |
||||
originScale: 1, |
||||
}) |
||||
}, |
||||
fail: () => { |
||||
Toast('图片加载失败') |
||||
}, |
||||
}) |
||||
}, |
||||
|
||||
onTouchStart(e: WechatMiniprogram.TouchEvent) { |
||||
const touches = e.touches |
||||
if (touches.length === 1) { |
||||
this.setData({ |
||||
lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }], |
||||
}) |
||||
} |
||||
else if (touches.length === 2) { |
||||
const lastTouches = this.data.lastTouches |
||||
if (lastTouches.length < 2) { |
||||
this.setData({ |
||||
lastTouches: [ |
||||
{ x: touches[0].clientX, y: touches[0].clientY }, |
||||
{ x: touches[1].clientX, y: touches[1].clientY }, |
||||
], |
||||
}) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
onTouchMove(e: WechatMiniprogram.TouchEvent) { |
||||
const touches = e.touches |
||||
|
||||
if (touches.length === 1) { |
||||
const lastTouches = this.data.lastTouches |
||||
if (lastTouches.length === 1) { |
||||
const dx = touches[0].clientX - lastTouches[0].x |
||||
const dy = touches[0].clientY - lastTouches[0].y |
||||
|
||||
this.setData({ |
||||
imgX: this.data.imgX + dx, |
||||
imgY: this.data.imgY + dy, |
||||
lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }], |
||||
}) |
||||
} |
||||
} |
||||
else if (touches.length === 2) { |
||||
const lastTouches = this.data.lastTouches |
||||
if (lastTouches.length === 2) { |
||||
const currentDist = Math.hypot( |
||||
touches[0].clientX - touches[1].clientX, |
||||
touches[0].clientY - touches[1].clientY, |
||||
) |
||||
const lastDist = Math.hypot( |
||||
lastTouches[0].x - lastTouches[1].x, |
||||
lastTouches[0].y - lastTouches[1].y, |
||||
) |
||||
|
||||
let newScale = this.data.scale * (currentDist / lastDist) |
||||
newScale = Math.max(this.data.minScale, Math.min(this.data.maxScale, newScale)) |
||||
|
||||
const centerX = (touches[0].clientX + touches[1].clientX) / 2 |
||||
const centerY = (touches[0].clientY + touches[1].clientY) / 2 |
||||
const lastCenterX = (lastTouches[0].x + lastTouches[1].x) / 2 |
||||
const lastCenterY = (lastTouches[0].y + lastTouches[1].y) / 2 |
||||
|
||||
const dx = centerX - lastCenterX |
||||
const dy = centerY - lastCenterY |
||||
|
||||
const imgCenterX = this.data.imgX + (this.data.imgWidth * this.data.scale) / 2 |
||||
const imgCenterY = this.data.imgY + (this.data.imgHeight * this.data.scale) / 2 |
||||
|
||||
const newImgX = imgCenterX - (this.data.imgWidth * newScale) / 2 |
||||
const newImgY = imgCenterY - (this.data.imgHeight * newScale) / 2 |
||||
|
||||
this.setData({ |
||||
scale: newScale, |
||||
imgX: newImgX + dx, |
||||
imgY: newImgY + dy, |
||||
lastTouches: [ |
||||
{ x: touches[0].clientX, y: touches[0].clientY }, |
||||
{ x: touches[1].clientX, y: touches[1].clientY }, |
||||
], |
||||
}) |
||||
} |
||||
} |
||||
}, |
||||
|
||||
onTouchEnd() { |
||||
this.setData({ lastTouches: [] }) |
||||
}, |
||||
|
||||
chooseNewImage() { |
||||
wx.chooseMedia({ |
||||
count: 1, |
||||
mediaType: ['image'], |
||||
sourceType: ['album', 'camera'], |
||||
success: (res) => { |
||||
if (res.tempFiles.length > 0) { |
||||
this.initImage(res.tempFiles[0].tempFilePath) |
||||
} |
||||
}, |
||||
}) |
||||
}, |
||||
|
||||
saveCroppedImage() { |
||||
Toast.loading({ message: '裁剪中...', forbidClick: true }) |
||||
|
||||
const { imageSrc, imgWidth, imgHeight, imgX, imgY, scale, cropSize } = this.data |
||||
|
||||
const query = wx.createSelectorQuery() |
||||
query |
||||
.select('#cropCanvas') |
||||
.fields({ node: true, size: true }) |
||||
.exec((res) => { |
||||
if (!res[0]) { |
||||
Toast.clear() |
||||
Toast('裁剪失败') |
||||
return |
||||
} |
||||
|
||||
const canvas = res[0].node |
||||
const ctx = canvas.getContext('2d') |
||||
const pixelRatio = wx.getWindowInfo().pixelRatio |
||||
|
||||
canvas.width = cropSize * pixelRatio |
||||
canvas.height = cropSize * pixelRatio |
||||
|
||||
ctx.fillStyle = '#ffffff' |
||||
ctx.fillRect(0, 0, canvas.width, canvas.height) |
||||
|
||||
const img = canvas.createImage() |
||||
img.src = imageSrc |
||||
|
||||
img.onload = () => { |
||||
const sx = (-imgX / scale) * pixelRatio |
||||
const sy = (-imgY / scale) * pixelRatio |
||||
const sWidth = (imgWidth / scale) * pixelRatio |
||||
const sHeight = (imgHeight / scale) * pixelRatio |
||||
|
||||
ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, canvas.width, canvas.height) |
||||
|
||||
wx.canvasToTempFilePath({ |
||||
canvas, |
||||
x: 0, |
||||
y: 0, |
||||
width: canvas.width, |
||||
height: canvas.height, |
||||
destWidth: cropSize, |
||||
destHeight: cropSize, |
||||
fileType: 'png', |
||||
quality: 1, |
||||
success: (result) => { |
||||
Toast.clear() |
||||
wx.saveImageToPhotosAlbum({ |
||||
filePath: result.tempFilePath, |
||||
success: () => { |
||||
Toast.success('保存成功') |
||||
}, |
||||
fail: () => { |
||||
Toast.fail('保存失败,请检查权限') |
||||
}, |
||||
}) |
||||
}, |
||||
fail: () => { |
||||
Toast.clear() |
||||
Toast('裁剪失败') |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
img.onerror = () => { |
||||
Toast.clear() |
||||
Toast('图片加载失败') |
||||
} |
||||
}) |
||||
}, |
||||
}) |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
<van-toast id="van-toast" /> |
||||
|
||||
<view class="container"> |
||||
<view class="crop-wrapper"> |
||||
<view class="crop-inner"> |
||||
<view class="crop-area" style="width: {{cropSize}}px; height: {{cropSize}}px;"> |
||||
<image |
||||
class="source-image" |
||||
src="{{imageSrc}}" |
||||
style="width: {{imgWidth}}px; height: {{imgHeight}}px; transform: translate({{imgX}}px, {{imgY}}px) scale({{scale}});" |
||||
bindtouchstart="onTouchStart" |
||||
bindtouchmove="onTouchMove" |
||||
bindtouchend="onTouchEnd" |
||||
/> |
||||
</view> |
||||
|
||||
<view class="crop-frame" style="width: {{cropSize}}px; height: {{cropSize}}px;"> |
||||
<view class="frame-corner left-top"></view> |
||||
<view class="frame-corner right-top"></view> |
||||
<view class="frame-corner left-bottom"></view> |
||||
<view class="frame-corner right-bottom"></view> |
||||
</view> |
||||
</view> |
||||
</view> |
||||
|
||||
<view class="tip-text"> |
||||
<van-icon name="info-o" size="14px" color="#999" /> |
||||
<text>移动和缩放图片,调整裁剪区域</text> |
||||
</view> |
||||
|
||||
<view class="action-buttons"> |
||||
<van-button type="default" round block bindtap="chooseNewImage"> |
||||
重新选择图片 |
||||
</van-button> |
||||
<van-button type="primary" round block custom-style="margin-top: 20rpx;" bindtap="saveCroppedImage"> |
||||
保存裁剪图片 |
||||
</van-button> |
||||
</view> |
||||
</view> |
||||
|
||||
<canvas type="2d" id="cropCanvas" class="crop-canvas" style="width: {{cropSize}}px; height: {{cropSize}}px;"></canvas> |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
{ |
||||
"navigationBarTitleText": "图片拼接", |
||||
"usingComponents": { |
||||
"van-button": "@vant/weapp/button/index", |
||||
"van-icon": "@vant/weapp/icon/index", |
||||
"van-toast": "@vant/weapp/toast/index", |
||||
"van-dialog": "@vant/weapp/dialog/index", |
||||
"van-loading": "@vant/weapp/loading/index" |
||||
} |
||||
} |
||||
@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
|
||||
page { |
||||
background-color: #f5f5f5; |
||||
min-height: 100vh; |
||||
} |
||||
|
||||
.container { |
||||
padding: 30rpx; |
||||
} |
||||
|
||||
.header { |
||||
text-align: center; |
||||
margin-bottom: 40rpx; |
||||
|
||||
.title { |
||||
display: block; |
||||
font-size: 40rpx; |
||||
font-weight: bold; |
||||
color: #333; |
||||
margin-bottom: 10rpx; |
||||
} |
||||
|
||||
.subtitle { |
||||
display: block; |
||||
font-size: 26rpx; |
||||
color: #999; |
||||
} |
||||
} |
||||
|
||||
.upload-section { |
||||
background-color: #fff; |
||||
border-radius: 16rpx; |
||||
padding: 30rpx; |
||||
margin-bottom: 30rpx; |
||||
} |
||||
|
||||
.section-title { |
||||
font-size: 30rpx; |
||||
font-weight: 500; |
||||
color: #333; |
||||
margin-bottom: 20rpx; |
||||
} |
||||
|
||||
.image-grid { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 20rpx; |
||||
} |
||||
|
||||
.image-item { |
||||
position: relative; |
||||
width: calc((100% - 40rpx) / 3); |
||||
aspect-ratio: 1; |
||||
border-radius: 12rpx; |
||||
overflow: hidden; |
||||
|
||||
.preview-img { |
||||
width: 100%; |
||||
height: 100%; |
||||
} |
||||
|
||||
.delete-btn { |
||||
position: absolute; |
||||
top: 8rpx; |
||||
right: 8rpx; |
||||
width: 40rpx; |
||||
height: 40rpx; |
||||
background-color: rgba(0, 0, 0, 0.5); |
||||
border-radius: 50%; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
} |
||||
|
||||
.add-btn { |
||||
width: calc((100% - 40rpx) / 3); |
||||
aspect-ratio: 1; |
||||
border: 2rpx dashed #ddd; |
||||
border-radius: 12rpx; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
justify-content: center; |
||||
background-color: #fafafa; |
||||
|
||||
.add-text { |
||||
font-size: 24rpx; |
||||
color: #999; |
||||
margin-top: 10rpx; |
||||
} |
||||
} |
||||
|
||||
.action-section { |
||||
margin-bottom: 30rpx; |
||||
} |
||||
|
||||
.result-section { |
||||
background-color: #fff; |
||||
border-radius: 16rpx; |
||||
padding: 30rpx; |
||||
margin-bottom: 30rpx; |
||||
} |
||||
|
||||
.result-preview { |
||||
position: relative; |
||||
border-radius: 12rpx; |
||||
overflow: hidden; |
||||
background-color: #f5f5f5; |
||||
|
||||
.merged-img { |
||||
width: 100%; |
||||
display: block; |
||||
} |
||||
|
||||
.preview-hint { |
||||
position: absolute; |
||||
bottom: 0; |
||||
left: 0; |
||||
right: 0; |
||||
padding: 20rpx; |
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.5)); |
||||
color: #fff; |
||||
font-size: 24rpx; |
||||
text-align: center; |
||||
} |
||||
} |
||||
|
||||
.tips { |
||||
display: flex; |
||||
align-items: flex-start; |
||||
justify-content: center; |
||||
padding: 40rpx; |
||||
text-align: center; |
||||
|
||||
text { |
||||
font-size: 24rpx; |
||||
color: #999; |
||||
margin-left: 10rpx; |
||||
line-height: 1.5; |
||||
} |
||||
} |
||||
|
||||
// 隐藏的Canvas |
||||
.merge-canvas { |
||||
position: fixed; |
||||
left: -9999px; |
||||
top: -9999px; |
||||
width: 1px; |
||||
height: 1px; |
||||
} |
||||
|
||||
// 预览弹窗 |
||||
.preview-modal { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background-color: rgba(0, 0, 0, 0.9); |
||||
z-index: 1000; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
|
||||
.preview-content { |
||||
width: 90%; |
||||
max-height: 80%; |
||||
overflow: auto; |
||||
|
||||
.full-img { |
||||
width: 100%; |
||||
} |
||||
} |
||||
|
||||
.close-btn { |
||||
position: absolute; |
||||
top: 40rpx; |
||||
right: 40rpx; |
||||
width: 60rpx; |
||||
height: 60rpx; |
||||
background-color: rgba(255, 255, 255, 0.2); |
||||
border-radius: 50%; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
} |
||||
@ -0,0 +1,266 @@
@@ -0,0 +1,266 @@
|
||||
import Toast from '@vant/weapp/toast/toast' |
||||
|
||||
const app = getApp<IAppOption>() |
||||
|
||||
Page({ |
||||
data: { |
||||
imageList: [] as string[], |
||||
imageTimes: [] as string[], // 存储每张图片的拍摄时间
|
||||
mergedImage: '', |
||||
canvasWidth: 0, |
||||
canvasHeight: 0, |
||||
isLoading: false, |
||||
previewShow: false, |
||||
}, |
||||
|
||||
onLoad() { |
||||
// 页面加载
|
||||
}, |
||||
|
||||
// 选择图片
|
||||
chooseImage() { |
||||
const remainingCount = 9 - this.data.imageList.length |
||||
if (remainingCount <= 0) { |
||||
Toast('最多只能选择9张图片') |
||||
return |
||||
} |
||||
|
||||
wx.chooseMedia({ |
||||
count: remainingCount, |
||||
mediaType: ['image'], |
||||
sourceType: ['album', 'camera'], |
||||
success: (res) => { |
||||
const newImages = res.tempFiles.map((file) => file.tempFilePath) |
||||
// 获取当前时间作为图片时间
|
||||
const now = new Date() |
||||
const timeStr = this.formatTime(now) |
||||
const newTimes = res.tempFiles.map(() => timeStr) |
||||
|
||||
this.setData({ |
||||
imageList: [...this.data.imageList, ...newImages], |
||||
imageTimes: [...this.data.imageTimes, ...newTimes], |
||||
}) |
||||
}, |
||||
}) |
||||
}, |
||||
|
||||
// 格式化时间
|
||||
formatTime(date: Date): string { |
||||
const year = date.getFullYear() |
||||
const month = String(date.getMonth() + 1).padStart(2, '0') |
||||
const day = String(date.getDate()).padStart(2, '0') |
||||
const hours = String(date.getHours()).padStart(2, '0') |
||||
const minutes = String(date.getMinutes()).padStart(2, '0') |
||||
return `${year}-${month}-${day} ${hours}:${minutes}` |
||||
}, |
||||
|
||||
// 删除图片
|
||||
deleteImage(e: WechatMiniprogram.TouchEvent) { |
||||
const index = e.currentTarget.dataset.index |
||||
const newList = [...this.data.imageList] |
||||
const newTimes = [...this.data.imageTimes] |
||||
newList.splice(index, 1) |
||||
newTimes.splice(index, 1) |
||||
this.setData({ |
||||
imageList: newList, |
||||
imageTimes: newTimes, |
||||
}) |
||||
}, |
||||
|
||||
// 预览单张图片
|
||||
previewImage(e: WechatMiniprogram.TouchEvent) { |
||||
const index = e.currentTarget.dataset.index |
||||
wx.previewImage({ |
||||
current: this.data.imageList[index], |
||||
urls: this.data.imageList, |
||||
}) |
||||
}, |
||||
|
||||
// 拼接图片
|
||||
async mergeImages() { |
||||
if (this.data.imageList.length < 2) { |
||||
Toast('至少需要2张图片才能拼接') |
||||
return |
||||
} |
||||
|
||||
this.setData({ isLoading: true }) |
||||
|
||||
try { |
||||
const mergedImage = await this.drawImagesOnCanvas() |
||||
this.setData({ |
||||
mergedImage, |
||||
isLoading: false, |
||||
}) |
||||
Toast('拼接成功') |
||||
} catch (error) { |
||||
console.error('拼接失败:', error) |
||||
this.setData({ isLoading: false }) |
||||
Toast('拼接失败,请重试') |
||||
} |
||||
}, |
||||
|
||||
// 在Canvas上绘制图片(纵向拼接)
|
||||
drawImagesOnCanvas(): Promise<string> { |
||||
return new Promise((resolve, reject) => { |
||||
const { imageList, imageTimes } = this.data |
||||
const query = wx.createSelectorQuery() |
||||
query |
||||
.select('#mergeCanvas') |
||||
.fields({ node: true, size: true }) |
||||
.exec((res) => { |
||||
if (!res[0]) { |
||||
reject(new Error('Canvas not found')) |
||||
return |
||||
} |
||||
|
||||
const canvas = res[0].node |
||||
const ctx = canvas.getContext('2d') |
||||
|
||||
// 设置画布背景为白色
|
||||
ctx.fillStyle = '#ffffff' |
||||
|
||||
// 加载所有图片并获取尺寸
|
||||
Promise.all( |
||||
imageList.map((src) => this.getImageInfo(src)) |
||||
).then((imageInfos) => { |
||||
// 计算目标宽度(取最大宽度或固定宽度)
|
||||
const targetWidth = 750 // 固定宽度为750rpx对应的像素
|
||||
const pixelRatio = wx.getWindowInfo().pixelRatio |
||||
const canvasWidth = Math.floor(targetWidth * pixelRatio / 2) // 转换为实际像素
|
||||
|
||||
// 底部时间文字区域高度
|
||||
const timeAreaHeight = 60 |
||||
// 右下角水印区域高度
|
||||
const watermarkHeight = 80 |
||||
|
||||
// 计算每张图片缩放后的高度
|
||||
let totalHeight = 0 |
||||
const scaledHeights: number[] = [] |
||||
|
||||
imageInfos.forEach((info) => { |
||||
const scale = canvasWidth / info.width |
||||
const scaledHeight = Math.floor(info.height * scale) |
||||
scaledHeights.push(scaledHeight) |
||||
totalHeight += scaledHeight + timeAreaHeight |
||||
}) |
||||
|
||||
// 添加水印区域高度
|
||||
totalHeight += watermarkHeight |
||||
|
||||
// 设置画布尺寸
|
||||
canvas.width = canvasWidth |
||||
canvas.height = totalHeight |
||||
|
||||
// 填充白色背景
|
||||
ctx.fillRect(0, 0, canvasWidth, totalHeight) |
||||
|
||||
// 绘制所有图片
|
||||
let currentY = 0 |
||||
let loadedCount = 0 |
||||
|
||||
imageInfos.forEach((info, index) => { |
||||
const img = canvas.createImage() |
||||
img.src = info.path |
||||
img.onload = () => { |
||||
// 绘制图片
|
||||
ctx.drawImage(img, 0, currentY, canvasWidth, scaledHeights[index]) |
||||
|
||||
// 绘制时间文字(图片底部中间)
|
||||
const timeY = currentY + scaledHeights[index] + 40 |
||||
ctx.fillStyle = '#666666' |
||||
ctx.font = '24px sans-serif' |
||||
ctx.textAlign = 'center' |
||||
ctx.fillText(imageTimes[index] || this.formatTime(new Date()), canvasWidth / 2, timeY) |
||||
|
||||
currentY += scaledHeights[index] + timeAreaHeight |
||||
loadedCount++ |
||||
|
||||
if (loadedCount === imageInfos.length) { |
||||
// 所有图片绘制完成,绘制右下角水印
|
||||
ctx.fillStyle = '#999999' |
||||
ctx.font = '28px sans-serif' |
||||
ctx.textAlign = 'right' |
||||
ctx.fillText('TED关爱小助手', canvasWidth - 30, totalHeight - 30) |
||||
|
||||
// 导出图片
|
||||
wx.canvasToTempFilePath({ |
||||
canvas, |
||||
success: (result) => { |
||||
resolve(result.tempFilePath) |
||||
}, |
||||
fail: reject, |
||||
}) |
||||
} |
||||
} |
||||
img.onerror = () => { |
||||
reject(new Error(`Failed to load image: ${info.path}`)) |
||||
} |
||||
}) |
||||
}).catch(reject) |
||||
}) |
||||
}) |
||||
}, |
||||
|
||||
// 获取图片信息
|
||||
getImageInfo(src: string): Promise<WechatMiniprogram.GetImageInfoSuccessCallbackResult> { |
||||
return new Promise((resolve, reject) => { |
||||
wx.getImageInfo({ |
||||
src, |
||||
success: resolve, |
||||
fail: reject, |
||||
}) |
||||
}) |
||||
}, |
||||
|
||||
// 预览拼接结果
|
||||
previewMergedImage() { |
||||
if (!this.data.mergedImage) return |
||||
this.setData({ previewShow: true }) |
||||
}, |
||||
|
||||
// 关闭预览
|
||||
closePreview() { |
||||
this.setData({ previewShow: false }) |
||||
}, |
||||
|
||||
// 保存图片到相册
|
||||
saveImage() { |
||||
if (!this.data.mergedImage) { |
||||
Toast('请先拼接图片') |
||||
return |
||||
} |
||||
|
||||
wx.saveImageToPhotosAlbum({ |
||||
filePath: this.data.mergedImage, |
||||
success: () => { |
||||
Toast('保存成功') |
||||
}, |
||||
fail: (err) => { |
||||
console.error('保存失败:', err) |
||||
if (err.errMsg.includes('auth')) { |
||||
wx.showModal({ |
||||
title: '提示', |
||||
content: '需要授权保存到相册权限', |
||||
success: (res) => { |
||||
if (res.confirm) { |
||||
wx.openSetting() |
||||
} |
||||
}, |
||||
}) |
||||
} else { |
||||
Toast('保存失败') |
||||
} |
||||
}, |
||||
}) |
||||
}, |
||||
|
||||
// 重新选择
|
||||
reset() { |
||||
this.setData({ |
||||
imageList: [], |
||||
imageTimes: [], |
||||
mergedImage: '', |
||||
previewShow: false, |
||||
}) |
||||
}, |
||||
}) |
||||
@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
|
||||
<van-toast id="van-toast" /> |
||||
|
||||
<view class="container"> |
||||
<!-- 标题 --> |
||||
<view class="header"> |
||||
<text class="title">图片拼接工具</text> |
||||
<text class="subtitle">选择多张图片,自动拼接成长图</text> |
||||
</view> |
||||
|
||||
<!-- 图片上传区域 --> |
||||
<view class="upload-section"> |
||||
<view class="section-title"> |
||||
<text>选择图片({{imageList.length}}/9)</text> |
||||
</view> |
||||
|
||||
<view class="image-grid"> |
||||
<!-- 已选择的图片 --> |
||||
<view |
||||
class="image-item" |
||||
wx:for="{{imageList}}" |
||||
wx:key="index" |
||||
data-index="{{index}}" |
||||
bindtap="previewImage" |
||||
> |
||||
<image class="preview-img" src="{{item}}" mode="aspectFill" /> |
||||
<view class="delete-btn" catchtap="deleteImage" data-index="{{index}}"> |
||||
<van-icon name="cross" size="12px" color="#fff" /> |
||||
</view> |
||||
</view> |
||||
|
||||
<!-- 添加按钮 --> |
||||
<view class="add-btn" bindtap="chooseImage" wx:if="{{imageList.length < 9}}"> |
||||
<van-icon name="plus" size="32px" color="#999" /> |
||||
<text class="add-text">添加图片</text> |
||||
</view> |
||||
</view> |
||||
</view> |
||||
|
||||
<!-- 操作按钮 --> |
||||
<view class="action-section" wx:if="{{imageList.length > 0}}"> |
||||
<van-button |
||||
type="primary" |
||||
block |
||||
round |
||||
loading="{{isLoading}}" |
||||
loading-text="拼接中..." |
||||
disabled="{{imageList.length < 2}}" |
||||
bindtap="mergeImages" |
||||
> |
||||
{{imageList.length < 2 ? '至少选择2张图片' : '开始拼接'}} |
||||
</van-button> |
||||
|
||||
<van-button |
||||
type="default" |
||||
block |
||||
round |
||||
custom-style="margin-top: 20rpx;" |
||||
bindtap="reset" |
||||
> |
||||
重新选择 |
||||
</van-button> |
||||
</view> |
||||
|
||||
<!-- 拼接结果预览 --> |
||||
<view class="result-section" wx:if="{{mergedImage}}"> |
||||
<view class="section-title"> |
||||
<text>拼接结果</text> |
||||
</view> |
||||
|
||||
<view class="result-preview" bindtap="previewMergedImage"> |
||||
<image class="merged-img" src="{{mergedImage}}" mode="widthFix" /> |
||||
<view class="preview-hint">点击预览大图</view> |
||||
</view> |
||||
|
||||
<van-button |
||||
type="primary" |
||||
block |
||||
round |
||||
custom-style="margin-top: 30rpx;" |
||||
bindtap="saveImage" |
||||
> |
||||
保存到相册 |
||||
</van-button> |
||||
</view> |
||||
|
||||
<!-- 提示信息 --> |
||||
<view class="tips" wx:if="{{imageList.length === 0}}"> |
||||
<van-icon name="info-o" size="16px" color="#999" /> |
||||
<text>支持上传2-9张图片,建议图片宽度相近以获得更好的拼接效果</text> |
||||
</view> |
||||
</view> |
||||
|
||||
<!-- Canvas(隐藏)用于图片拼接 --> |
||||
<canvas |
||||
type="2d" |
||||
id="mergeCanvas" |
||||
class="merge-canvas" |
||||
></canvas> |
||||
|
||||
<!-- 全屏预览弹窗 --> |
||||
<view class="preview-modal" wx:if="{{previewShow}}" bindtap="closePreview"> |
||||
<view class="preview-content"> |
||||
<image class="full-img" src="{{mergedImage}}" mode="widthFix" /> |
||||
</view> |
||||
<view class="close-btn"> |
||||
<van-icon name="cross" size="24px" color="#fff" /> |
||||
</view> |
||||
</view> |
||||
Loading…
Reference in new issue