Files
miniprogram-1/miniprogram/pages/avatar_crop/avatar_crop.ts
2026-01-05 11:50:51 +08:00

343 lines
11 KiB
TypeScript
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.
Page({
data: {
src: '',
width: 0,
height: 0,
x: 0,
y: 0,
scale: 1,
minScale: 0.1, // 初始设小一点,防止默认值限制
maxScale: 10,
// 图片原始尺寸
imgWidth: 0,
imgHeight: 0,
// 屏幕尺寸
windowWidth: 0,
windowHeight: 0,
// 裁剪框尺寸(正方形)
cropSize: 0,
cropTop: 0,
cropLeft: 0
},
onLoad(options: any) {
const src = options.src
if (src) {
this.setData({ src: decodeURIComponent(src) })
this.initLayout()
} else {
wx.showToast({ title: '未找到图片', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
initLayout() {
const sys = wx.getSystemInfoSync()
const windowWidth = sys.windowWidth
const windowHeight = sys.windowHeight
// 设置裁剪框大小(屏幕宽度 - 40px留出边距
const cropSize = windowWidth - 40
const cropLeft = (windowWidth - cropSize) / 2
const cropTop = (windowHeight - cropSize) / 2
this.setData({
windowWidth,
windowHeight,
cropSize,
cropLeft,
cropTop,
width: windowWidth // 初始宽度设为屏幕宽
})
},
onReady() {
// 布局已由 JS 统一控制,无需再探测 DOM
// 如果图片已经加载,确保位置正确
if (this.data.imgWidth && this.data.imgHeight) {
// 延迟一点确保 setData 生效
setTimeout(() => {
this.resetImagePosition(this.data.imgWidth, this.data.imgHeight)
}, 50)
}
},
// 内部状态,避免频繁 setData
_x: 0,
_y: 0,
_scale: 1,
onImageLoad(e: any) {
const { width, height } = e.detail
this.setData({
imgWidth: width,
imgHeight: height
})
this.resetImagePosition(width, height)
},
resetImagePosition(imgWidth: number, imgHeight: number) {
// 初始化图片位置:居中显示
// movable-view 的初始 x, y 是相对于 movable-area 的左上角
// 我们希望图片中心对齐裁剪框中心
// 计算图片在屏幕上的显示尺寸widthFix 模式下,宽度等于 movable-view 宽度)
// movable-view 宽度初始设为 windowWidth
const displayWidth = this.data.windowWidth
const displayHeight = (imgHeight / imgWidth) * displayWidth
// 计算最小缩放比例,确保图片覆盖裁剪框
const { cropSize, cropTop, cropLeft, windowWidth } = this.data
// 宽或高必须至少填满裁剪框
// 增加一点点冗余 (1.05倍) 防止浮点数精度问题导致的白边,确保完全覆盖
const minScale = Math.max(cropSize / displayWidth, cropSize / displayHeight) * 1.05
// 动态计算最大缩放比例,确保有足够的缩放空间
const maxScale = Math.max(minScale * 5, 10)
// 初始位置:
// x: 让图片水平中心与裁剪框中心重合
// y: 与裁剪框顶部对齐响应用户反馈movable-view y 应与 crop-box top 一致)
// 裁剪框中心坐标
const cropCenterX = cropLeft + cropSize / 2
// 图片(未缩放)中心应该是 (x + displayWidth/2)
const x = cropCenterX - displayWidth / 2
// 垂直方向直接对齐顶部
const y = cropTop
// 初始缩放比例:直接设为最小缩放比例,确保刚好覆盖
const scale = minScale
// 更新内部状态
this._x = x
this._y = y
this._scale = scale
// 1. 先设置尺寸和计算出的约束参数minScale/maxScale 仅用于 JS 逻辑控制,不绑定到 WXML
this.setData({
width: displayWidth,
height: displayHeight,
minScale,
maxScale
}, () => {
// 2. 延迟设置位置和缩放
// 分离设置是为了防止 movable-view 在尺寸未更新前应用位置导致错位
setTimeout(() => {
this.setData({
x,
y,
scale
})
// 立即执行一次 checkBounds 以确保位置合法
// 注意:这里 checkBounds 会读取 this.data.scale (可能是 1),与传入的 scale (例如 1.42) 不同
// 从而触发一次校正 setData这是预期的
this.checkBounds(x, y, scale)
}, 50)
})
},
onChange(e: any) {
// 始终更新内部状态,保持同步
this._x = e.detail.x
this._y = e.detail.y
if (e.detail.scale) {
this._scale = e.detail.scale
}
if (e.detail.source === 'touch' || e.detail.source === 'touch-out-of-bounds') {
this.checkBounds(this._x, this._y, this._scale)
}
},
onScale(e: any) {
// 始终更新内部状态,保持同步
this._scale = e.detail.scale
this._x = e.detail.x
this._y = e.detail.y
if (e.detail.source === 'touch' || e.detail.source === 'touch-out-of-bounds') {
this.checkBounds(this._x, this._y, this._scale)
}
},
checkBounds(x: number, y: number, scale: number) {
const { width, height, cropLeft, cropTop, cropSize, minScale } = this.data
// 0. 强制缩放限制
if (scale < minScale) {
scale = minScale
}
// 假设 movable-view 的 x,y 是视觉左上角坐标(或系统自动处理了 transform-origin
// 之前基于中心缩放的 offset 计算导致了边界判断错误(偏大),从而强制图片下移
// 计算合法的移动范围
// 图片左边缘 x <= cropLeft
const max_x = cropLeft
// 图片右边缘 x + width * scale >= cropLeft + cropSize
// x >= cropLeft + cropSize - width * scale
const min_x = cropLeft + cropSize - width * scale
// y轴同理
// 图片上边缘 y <= cropTop
const max_y = cropTop
// 图片下边缘 y + height * scale >= cropTop + cropSize
const min_y = cropTop + cropSize - height * scale
let new_x = x
let new_y = y
// 修正 x
// 增加 2px 的容差缓冲,防止因浮点数计算精度导致的边界吸附过早
if (new_x > max_x + 2) new_x = max_x
if (new_x < min_x - 2) new_x = min_x
// 修正 y
if (new_y > max_y + 2) new_y = max_y
if (new_y < min_y - 2) new_y = min_y
// 只有当位置需要修正时,或者 scale 发生变化时才 setData
if (Math.abs(new_x - x) > 1 || Math.abs(new_y - y) > 1 || Math.abs(scale - this.data.scale) > 0.001) {
this._x = new_x
this._y = new_y
this.setData({
x: new_x,
y: new_y,
scale: scale
})
}
},
onCancel() {
wx.navigateBack()
},
async onConfirm() {
wx.showLoading({ title: '裁剪中...' })
try {
// 获取 Canvas 节点
const query = wx.createSelectorQuery()
query.select('#cropCanvas')
.fields({ node: true, size: true })
.exec(async (res) => {
const canvas = res[0].node
const ctx = canvas.getContext('2d')
// 设置导出尺寸(例如 512x512
const exportSize = 512
canvas.width = exportSize
canvas.height = exportSize
// 计算绘制参数
// 目标:将裁剪框内的图片区域绘制到 canvas 上
// 1. 获取当前图片的状态
// 使用内部状态获取最新的位置和缩放(比 setData 更新的快)
const { width: displayWidth, height: displayHeight, cropSize, cropLeft, cropTop, src } = this.data
const x = this._x
const y = this._y
const scale = this._scale
// 2. 创建图片对象
const img = canvas.createImage()
img.src = src
img.onload = () => {
// 计算裁剪区域在原图上的坐标
// movable-view 的缩放是基于中心的
// 缩放产生的偏移
const scaleOffsetX = displayWidth * (scale - 1) / 2
const scaleOffsetY = displayHeight * (scale - 1) / 2
// 视觉左上角坐标 vx
const vx = x - scaleOffsetX
const vy = y - scaleOffsetY
// 图片相对于裁剪框的偏移 (visual_offset)
// 通常是负值,因为图片左上角在裁剪框左上角的左上方
const offsetInCropX = vx - cropLeft
const offsetInCropY = vy - cropTop
// 此时图片显示的实际宽及高
const scaledWidth = displayWidth * scale
const scaledHeight = displayHeight * scale
// 绘制
// drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
// 这里我们用另一种方式:先清空,然后平移旋转缩放,再绘制图片
ctx.clearRect(0, 0, exportSize, exportSize)
// 映射关系:
// 裁剪框的 cropSize 对应 canvas 的 exportSize
const ratio = exportSize / cropSize
ctx.save()
// 移动 canvas 原点到图片在 canvas 中的起始位置
ctx.translate(offsetInCropX * ratio, offsetInCropY * ratio)
ctx.scale(ratio * scale, ratio * scale) // 缩放
// 绘制完整图片(因为 movable-view 的 x,y 是图片左上角)
// 注意displayWidth 是图片在 scale=1 时的显示宽度
// 我们需要绘制的是原始图片,但大小要匹配 displayWidth
// 实际上 drawImage(img, 0, 0, displayWidth, displayHeight)
ctx.drawImage(img, 0, 0, displayWidth, displayHeight)
ctx.restore()
// 导出
wx.canvasToTempFilePath({
canvas,
width: exportSize,
height: exportSize,
destWidth: exportSize,
destHeight: exportSize,
fileType: 'jpg',
quality: 0.9,
success: (res) => {
wx.hideLoading()
// 返回上一页并传递结果
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage) {
// 通过事件通道或直接调用方法传递
// 这里简单点,直接调用上一页的方法(如果存在)
if ((prevPage as any).onAvatarCropped) {
(prevPage as any).onAvatarCropped(res.tempFilePath)
}
}
wx.navigateBack()
},
fail: (err) => {
console.error(err)
wx.hideLoading()
wx.showToast({ title: '导出失败', icon: 'none' })
}
})
}
img.onerror = (err: any) => {
console.error(err)
wx.hideLoading()
wx.showToast({ title: '加载图片失败', icon: 'none' })
}
})
} catch (e) {
console.error(e)
wx.hideLoading()
wx.showToast({ title: '裁剪出错', icon: 'none' })
}
}
})