343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
|
||
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' })
|
||
}
|
||
}
|
||
}) |