import { gsap } from 'gsap' /** * 画布动画管理器 * 负责处理画布平移、缩放等动画效果 */ export class AnimationManager { /** * 创建动画管理器 * @param {fabric.Canvas} canvas fabric.js画布实例 * @param {Object} options 配置选项 */ constructor(canvas, options = {}) { this.canvas = canvas this.currentZoom = options.currentZoom || { value: 100 } // 动画相关属性 this._zoomAnimation = null this._panAnimation = null this._lastWheelTime = 0 this._lastWheelProcessTime = 0 // 上次处理wheel事件的时间 this._wheelEvents = [] // 检测设备类型,Mac设备使用更短的节流时间确保响应性 this._isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 this._wheelThrottleTime = this._isMac ? options.wheelThrottleTime || 8 // Mac设备使用更短的节流时间 : options.wheelThrottleTime || 30 this._accumulatedWheelDelta = 0 // 累积滚轮增量 this._wheelAccumulationTimeout = null // 滚轮累积超时 // Mac设备使用更短的累积时间窗口,确保及时响应 this._wheelAccumulationTime = this._isMac ? 60 : 120 // 滚轮累积时间窗口(毫秒) // 添加新的状态跟踪变量 this._wasPanning = false // 是否有平移动画正在进行 this._wasZooming = false // 是否有缩放动画正在进行 this._combinedAnimation = null // 组合动画引用 // Mac特有的动画优化变量 - 使用最小防抖机制 if (this._isMac) { this._lastMacAnimationTime = 0 // 上次Mac动画时间 this._macAnimationCooldown = 2 // 最小的动画冷却时间,确保最大响应性 } // 初始化GSAP默认配置 gsap.defaults({ ease: options.defaultEase || (this._isMac ? 'power2.out' : 'power2.out'), // Mac使用简单高效的缓动 duration: options.defaultDuration || (this._isMac ? 0.3 : 0.3), // Mac使用标准持续时间 overwrite: 'auto' // 自动覆盖同一对象上的动画 }) } /** * 使用 GSAP 实现平滑缩放动画 * @param {Object} point 缩放中心点 {x, y} * @param {Number} targetZoom 目标缩放值 * @param {Object} options 动画选项 */ animateZoom(point, targetZoom, options = {}) { if (!this.canvas) return // 限制缩放范围 targetZoom = Math.min(Math.max(targetZoom, 0.1), 20) // 当前缩放值 const currentZoom = this.canvas.getZoom() // 如果变化太小,直接应用缩放 if (Math.abs(targetZoom - currentZoom) < 0.01) { this._applyZoom(point, targetZoom) return } // 停止任何进行中的缩放动画 if (this._zoomAnimation) { // 不是直接 kill,而是获取当前进度值作为新的起点 const currentProgress = this._zoomAnimation.progress() const currentZoomValue = this._zoomAnimation.targets()[0].value this._zoomAnimation.kill() this._zoomAnimation = null // 从当前过渡中的值开始新动画,而不是从最初的值 const zoomObj = { value: currentZoomValue } const currentVpt = [...this.canvas.viewportTransform] // 计算过渡动画持续时间 - 根据当前值到目标值的距离比例 const progressRatio = Math.abs(targetZoom - currentZoomValue) / Math.abs(targetZoom - currentZoom) const duration = options.duration || 0.3 * progressRatio // 计算缩放后目标位置需要的修正,保持缩放点不变 const animOptions = { value: targetZoom, duration: duration, ease: options.ease || 'power2.out', onUpdate: () => { // 更新缩放值显示 this.currentZoom.value = Math.round(zoomObj.value * 100) // 计算过渡中的变换矩阵 const zoom = zoomObj.value const scale = zoom / currentZoomValue const currentScaleFactor = scale // 应用变换 const vpt = this.canvas.viewportTransform vpt[0] = currentVpt[0] * scale vpt[3] = currentVpt[3] * scale // 应用平移修正以保持缩放点 const adjustX = (1 - currentScaleFactor) * point.x const adjustY = (1 - currentScaleFactor) * point.y vpt[4] = currentVpt[4] * scale + adjustX vpt[5] = currentVpt[5] * scale + adjustY this.canvas.renderAll() }, onComplete: () => { this._zoomAnimation = null // 确保最终状态准确 this._applyZoom(point, targetZoom, true) } } // 启动 GSAP 动画 this._zoomAnimation = gsap.to(zoomObj, animOptions) return } // 如果没有正在进行的动画,创建新的缩放动画 const zoomObj = { value: currentZoom } const currentVpt = [...this.canvas.viewportTransform] // 计算缩放后目标位置需要的修正,保持缩放点不变 const scaleFactor = targetZoom / currentZoom const invertedScaleFactor = 1 / scaleFactor // 这个数学公式确保缩放点在屏幕上的位置保持不变 const dx = point.x - point.x * invertedScaleFactor const dy = point.y - point.y * invertedScaleFactor // 创建动画配置 const animOptions = { value: targetZoom, duration: options.duration || 0.3, ease: options.ease || (this._isMac ? 'expo.out' : 'power2.out'), // Mac使用更平滑的缓动 onUpdate: () => { // 更新缩放值显示 this.currentZoom.value = Math.round(zoomObj.value * 100) // 计算过渡中的变换矩阵 const zoom = zoomObj.value const scale = zoom / currentZoom const currentScaleFactor = scale // 应用变换 const vpt = this.canvas.viewportTransform vpt[0] = currentVpt[0] * scale vpt[3] = currentVpt[3] * scale // 应用平移修正以保持缩放点 const adjustX = (1 - currentScaleFactor) * point.x const adjustY = (1 - currentScaleFactor) * point.y vpt[4] = currentVpt[4] * scale + adjustX vpt[5] = currentVpt[5] * scale + adjustY this.canvas.renderAll() }, onComplete: () => { this._zoomAnimation = null // 确保最终状态准确 this._applyZoom(point, targetZoom, true) } } // 启动 GSAP 动画 this._zoomAnimation = gsap.to(zoomObj, animOptions) } /** * 应用缩放(内部使用) * @private */ _applyZoom(point, zoom, skipUpdate = false) { if (!skipUpdate) { this.currentZoom.value = Math.round(zoom * 100) } this.canvas.zoomToPoint(point, zoom) } /** * 使用 GSAP 实现平滑平移动画 * @param {Object} targetPosition 目标位置 {x, y} * @param {Object} options 动画选项 */ animatePan(targetPosition, options = {}) { if (!this.canvas) return // 停止任何进行中的平移动画 if (this._panAnimation) { this._panAnimation.kill() } const currentVpt = [...this.canvas.viewportTransform] const position = { x: -currentVpt[4], y: -currentVpt[5] } // 计算平移距离 const dx = targetPosition.x - position.x const dy = targetPosition.y - position.y // 如果距离太小,直接应用平移 if (Math.abs(dx) < 1 && Math.abs(dy) < 1) { this._applyPan(targetPosition.x, targetPosition.y) return } // 创建动画配置 const animOptions = { x: targetPosition.x, y: targetPosition.y, duration: options.duration || 0.3, ease: options.ease || (this._isMac ? 'circ.out' : 'power2.out'), // Mac使用更柔和的缓动 onUpdate: () => { this._applyPan(position.x, position.y) }, onComplete: () => { this._panAnimation = null // 确保最终位置准确 this._applyPan(targetPosition.x, targetPosition.y) } } // 启动 GSAP 动画 this._panAnimation = gsap.to(position, animOptions) } /** * 应用平移(内部使用) * @private */ _applyPan(x, y) { if (!this.canvas) return const vpt = this.canvas.viewportTransform vpt[4] = -x vpt[5] = -y this.canvas.renderAll() } /** * 使用动画平移到指定元素 * @param {Object} elementId 元素ID */ panToElement(elementId) { if (!this.canvas) return const obj = this.canvas.getObjects().find((obj) => obj.id === elementId) if (!obj) return const zoom = this.canvas.getZoom() const center = obj.getCenterPoint() // 计算目标中心位置 const targetX = center.x * zoom - this.canvas.width / 2 const targetY = center.y * zoom - this.canvas.height / 2 // 动画平移 this.animatePan( { x: targetX, y: targetY }, { duration: 0.6, ease: this._isMac ? 'back.out(0.3)' : 'power3.out' // Mac使用轻微回弹效果 } ) } /** * 重置缩放(带平滑动画) * @param {Boolean} animated 是否使用动画 */ async resetZoom(animated = true) { return new Promise((resolve) => { if (animated) { // 停止任何进行中的动画 if (this._zoomAnimation) { this._zoomAnimation.kill() } if (this._panAnimation) { this._panAnimation.kill() } const center = { x: this.canvas.width / 2, y: this.canvas.height / 2 } // 获取当前变换矩阵 const currentVpt = [...this.canvas.viewportTransform] const currentZoom = this.canvas.getZoom() // 创建一个对象来动画整个视图变换 const viewTransform = { zoom: currentZoom, panX: currentVpt[4], panY: currentVpt[5] } // 使用GSAP同时动画缩放和平移 gsap.to(viewTransform, { zoom: 1, panX: 0, panY: 0, duration: 0.5, ease: this._isMac ? 'back.out(0.2)' : 'power3.out', // Mac使用轻微回弹效果 onUpdate: () => { // 更新缩放显示值 this.currentZoom.value = Math.round(viewTransform.zoom * 100) // 应用新的变换 const vpt = this.canvas.viewportTransform vpt[0] = viewTransform.zoom vpt[3] = viewTransform.zoom vpt[4] = viewTransform.panX vpt[5] = viewTransform.panY this.canvas.renderAll() }, onComplete: () => { // 确保最终状态准确 this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]) this.currentZoom.value = 100 this._zoomAnimation = null this._panAnimation = null resolve() } }) } else { this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]) this.currentZoom.value = 100 resolve() } }) } /** * 处理鼠标滚轮缩放 * @param {Object} opt 事件对象 */ handleMouseWheel(opt) { const now = Date.now() let delta = opt.e.deltaY // 记录事件用于计算速度和惯性 this._wheelEvents.push({ delta: delta, point: { x: opt.e.offsetX, y: opt.e.offsetY }, time: now, hasPanAnimation: this._wasPanning, hasZoomAnimation: this._wasZooming }) // 保留最近的事件记录 if (this._wheelEvents.length > 10) { this._wheelEvents.shift() } // 检查是否是第一个事件或者距离上次处理已经过了足够时间 const isFirstEvent = !this._wheelAccumulationTimeout const timeSinceLastProcess = now - (this._lastWheelProcessTime || 0) if (isFirstEvent || timeSinceLastProcess > this._wheelAccumulationTime) { // 立即处理第一个事件或长时间没有处理的事件,确保响应性 this._processAccumulatedWheel(opt) this._lastWheelProcessTime = now // 清理之前的累积 this._accumulatedWheelDelta = 0 // 如果有pending的timeout,清除它 if (this._wheelAccumulationTimeout) { clearTimeout(this._wheelAccumulationTimeout) this._wheelAccumulationTimeout = null } } else { // 累积后续事件 this._accumulatedWheelDelta += delta // 如果正在累积中,清除之前的定时器 if (this._wheelAccumulationTimeout) { clearTimeout(this._wheelAccumulationTimeout) } // 设置新的定时器,处理累积的事件 this._wheelAccumulationTimeout = setTimeout(() => { this._processAccumulatedWheel(opt) this._lastWheelProcessTime = Date.now() // 清理 this._accumulatedWheelDelta = 0 this._wheelAccumulationTimeout = null }, this._wheelThrottleTime) } opt.e.preventDefault() opt.e.stopPropagation() } /** * 处理累积的滚轮事件并应用缩放 * @private * @param {Object} lastOpt 最后一个滚轮事件 */ _processAccumulatedWheel(lastOpt) { if (!this._wheelEvents.length) return const now = Date.now() // Mac设备的轻量防抖检查 - 进一步减少冷却时间,确保响应性 if (this._isMac && now - this._lastMacAnimationTime < this._macAnimationCooldown) { // 如果距离上次动画时间太短,只延迟很短时间,不阻塞太久 if (this._wheelAccumulationTimeout) { clearTimeout(this._wheelAccumulationTimeout) } this._wheelAccumulationTimeout = setTimeout(() => { this._processAccumulatedWheel(lastOpt) }, Math.min(this._macAnimationCooldown, 3)) // 最多延迟3ms return } const currentZoom = this.canvas.getZoom() // 分析滚轮事件模式,计算平均增量、速度和加速度 let sumDelta = 0 let count = 0 let earliestTime = now let latestTime = 0 let point = { x: lastOpt.e.offsetX, y: lastOpt.e.offsetY } // 判断是否在事件收集期间有平移或缩放动画 let hadPanAnimation = false let hadZoomAnimation = false // 计算平均增量和速度 this._wheelEvents.forEach((event) => { sumDelta += event.delta count++ earliestTime = Math.min(earliestTime, event.time) latestTime = Math.max(latestTime, event.time) // 使用最后记录的点作为缩放中心 if (event.time > latestTime) { point = event.point } // 检查是否有动画状态 if (event.hasPanAnimation) hadPanAnimation = true if (event.hasZoomAnimation) hadZoomAnimation = true }) // 计算平均增量 const avgDelta = sumDelta / count // 计算滚动速度 - 基于事件频率和时间跨度 const timeSpan = latestTime - earliestTime + 1 // 避免除以零 const eventsPerSecond = (count / timeSpan) * 1000 // 速度系数: 速度越快,缩放越敏感 let speedFactor = Math.min(3, Math.max(0.5, eventsPerSecond / 10)) // 计算缩放因子,应用速度系数 // 针对Mac设备优化:Mac触控板的deltaY值通常较小,需要适度增加敏感度 let zoomFactorBase = 0.999 if (this._isMac) { // Mac设备的触控板需要适度的敏感度,避免过度反应 zoomFactorBase = 0.995 // 适度降低基数,增加缩放敏感度 // 检测是否为触控板滚动(小幅度、高频次的特征) const avgAbsDelta = Math.abs(avgDelta) if (avgAbsDelta < 50 && count > 2) { // 触控板滚动,适度增加敏感度 speedFactor *= 1.6 // 适度增加敏感度倍数 zoomFactorBase = 0.993 // 进一步调整基数 } } const zoomFactor = zoomFactorBase ** (avgDelta * speedFactor) let targetZoom = currentZoom * zoomFactor // 限制缩放范围 targetZoom = Math.min(Math.max(targetZoom, 0.1), 20) // 根据滚动速度和缩放幅度计算动画持续时间 // 速度快时缩短动画时间,缩放幅度大时延长动画时间 const zoomRatio = Math.abs(targetZoom - currentZoom) / currentZoom let duration if (this._isMac) { // Mac设备使用平衡的动画时间控制 if (speedFactor > 2) { // 快速操作:快速但平滑 duration = Math.min( 0.18, Math.max(0.08, (zoomRatio * 0.3) / Math.sqrt(speedFactor)) ) } else if (speedFactor > 1.2) { // 中等速度:标准响应 duration = Math.min(0.25, Math.max(0.1, (zoomRatio * 0.4) / Math.sqrt(speedFactor))) } else { // 慢速精确操作:确保平滑 duration = Math.min(0.3, Math.max(0.12, (zoomRatio * 0.5) / Math.sqrt(speedFactor))) } } else { duration = Math.min(0.5, Math.max(0.15, (zoomRatio * 0.8) / Math.sqrt(speedFactor))) } // 根据滚动速度选择不同的缓动效果 let easeType if (this._isMac) { // Mac设备使用更简单、性能更好的缓动函数 // 避免复杂的指数和回弹效果,减少计算量 if (speedFactor > 2) { // 快速滚动:使用简单的缓出效果 easeType = 'power2.out' } else if (speedFactor > 1.2) { // 中等速度:使用平滑的缓出 easeType = 'power1.out' } else { // 慢速精确操作:使用线性过渡 easeType = 'power1.out' } } else { // 非Mac设备保持原有的缓动 easeType = speedFactor > 1.5 ? 'power1.out' : 'power2.out' } // 根据是否有其他动画正在进行,选择合适的动画方法 if (hadPanAnimation || this._wasPanning) { // 如果有平移动画,使用组合动画以保持平滑过渡 this.animateCombinedTransform(point, targetZoom, { duration: duration, ease: easeType }) } else { // 如果没有其他动画,使用标准缩放动画 this.animateZoom(point, targetZoom, { duration: duration, ease: easeType }) } // 更新Mac设备的最后动画时间 if (this._isMac) { this._lastMacAnimationTime = now } // 清理事件记录 this._wheelEvents = [] } /** * 计算并应用拖动结束后的惯性效果 * @param {Array} positions 拖动过程中记录的位置数组 * @param {Boolean} isTouchDevice 是否是触摸设备 */ applyInertiaEffect(positions, isTouchDevice) { if (!positions || positions.length <= 1) return const lastPos = positions[positions.length - 1] const firstPos = positions[0] const deltaTime = lastPos.time - firstPos.time if (deltaTime <= 0) return // 计算速度向量 (像素/毫秒) const velocityX = (lastPos.x - firstPos.x) / deltaTime const velocityY = (lastPos.y - firstPos.y) / deltaTime const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) // 仅当速度足够大时应用惯性效果 if (speed > 0.2) { // 计算惯性距离,基于速度和衰减因子 const decayFactor = 300 // 调整此值以改变惯性效果的强度 const inertiaDistanceX = velocityX * decayFactor const inertiaDistanceY = velocityY * decayFactor // 计算目标位置 const vpt = this.canvas.viewportTransform const currentPos = { x: -vpt[4], y: -vpt[5] } const targetPos = { x: currentPos.x - inertiaDistanceX, y: currentPos.y - inertiaDistanceY } // 应用惯性动画,速度越大,动画时间越长 const animationDuration = Math.min(1.2, Math.max(0.6, speed * 2)) // 应用惯性动画 this.animatePan(targetPos, { duration: animationDuration, // 动态计算持续时间 ease: this._isMac ? 'quart.out' : 'power3.out' // Mac使用更自然的减速效果 }) } } /** * 平滑过渡停止所有动画 * 用于在需要中断当前动画时提供更自然的过渡,而不是硬性中断 * @param {Object} options 过渡选项 */ smoothStopAnimations(options = {}) { const duration = options.duration || 0.15 // 默认短暂过渡时间 // 处理缩放动画 if (this._zoomAnimation) { const zoomObj = this._zoomAnimation.targets()[0] const currentZoom = this.canvas.getZoom() // 创建短暂的过渡动画到当前值 gsap.to(zoomObj, { value: currentZoom, duration: duration, ease: this._isMac ? 'circ.out' : 'power1.out', // Mac使用更平滑的缓动 onUpdate: () => { this.currentZoom.value = Math.round(zoomObj.value * 100) this.canvas.renderAll() }, onComplete: () => { if (this._zoomAnimation) { this._zoomAnimation.kill() this._zoomAnimation = null } } }) } // 处理平移动画 if (this._panAnimation) { const panObj = this._panAnimation.targets()[0] const vpt = this.canvas.viewportTransform const currentPos = { x: -vpt[4], y: -vpt[5] } // 创建短暂的过渡动画到当前位置 gsap.to(panObj, { x: currentPos.x, y: currentPos.y, duration: duration, ease: this._isMac ? 'circ.out' : 'power1.out', // Mac使用更平滑的缓动 onUpdate: () => { this._applyPan(panObj.x, panObj.y) }, onComplete: () => { if (this._panAnimation) { this._panAnimation.kill() this._panAnimation = null } } }) } } /** * 设置画布交互动画 * 为对象交互添加流畅的动画效果 */ setupInteractionAnimations() { if (!this.canvas) return // 启用对象旋转的流畅动画 this._setupRotationAnimation() } /** * 设置旋转动画 * @private */ _setupRotationAnimation() { if (!fabric) return // 保存原始旋转方法 const originalRotate = fabric.Object.prototype.rotate const isMac = this._isMac // 保存Mac检测结果 // 覆盖旋转方法以添加动画 fabric.Object.prototype.rotate = function (angle) { const currentAngle = this.angle || 0 if (Math.abs(angle - currentAngle) > 0.1) { gsap.to(this, { angle: angle, duration: 0.3, ease: isMac ? 'back.out(0.3)' : 'power2.out', // Mac使用轻微回弹 onUpdate: () => { this.canvas && this.canvas.renderAll() } }) return this } // 如果角度差异很小,使用原始方法 return originalRotate.call(this, angle) } } /** * 处理滚轮缩放,同时兼容正在进行的平移动画 * @param {Object} point 缩放中心点 * @param {Number} targetZoom 目标缩放值 * @param {Object} options 动画选项 */ animateCombinedTransform(point, targetZoom, options = {}) { if (!this.canvas) return // 限制缩放范围 targetZoom = Math.min(Math.max(targetZoom, 0.1), 20) // 当前状态 const currentZoom = this.canvas.getZoom() const currentVpt = [...this.canvas.viewportTransform] const currentPos = { x: -currentVpt[4], y: -currentVpt[5] } // 如果有正在进行的动画,先停止它们 if (this._combinedAnimation) { this._combinedAnimation.kill() this._combinedAnimation = null } if (this._zoomAnimation) { this._zoomAnimation.kill() this._zoomAnimation = null } if (this._panAnimation) { this._panAnimation.kill() this._panAnimation = null } // 创建一个统一的变换对象来动画 const transform = { zoom: currentZoom, panX: currentVpt[4], panY: currentVpt[5], progress: 0 // 用于动画进度跟踪 } // 获取平移目标位置(如果有的话) let panTarget = { x: currentPos.x, y: currentPos.y } if (this._wasPanning) { // 如果之前有平移动画,尝试获取平移的目标位置 const vpt = this.canvas.viewportTransform panTarget = { x: currentPos.x, y: currentPos.y } } // 计算新的变换矩阵,同时考虑平移和缩放 const scaleFactor = targetZoom / currentZoom // 创建动画 this._combinedAnimation = gsap.to(transform, { zoom: targetZoom, progress: 1, duration: options.duration || 0.3, ease: options.ease || (this._isMac ? 'expo.out' : 'power2.out'), // Mac使用更平滑的缓动 onUpdate: () => { // 计算当前动画阶段的混合变换 const currentScaleFactor = transform.zoom / currentZoom // 应用缩放 const vpt = this.canvas.viewportTransform vpt[0] = currentVpt[0] * (transform.zoom / currentZoom) vpt[3] = currentVpt[3] * (transform.zoom / currentZoom) // 平滑混合平移和缩放调整 const adjustX = (1 - currentScaleFactor) * point.x const adjustY = (1 - currentScaleFactor) * point.y // 如果存在平移目标,进行插值 if (this._wasPanning) { const t = transform.progress const interpolatedX = currentPos.x * (1 - t) + panTarget.x * t const interpolatedY = currentPos.y * (1 - t) + panTarget.y * t // 结合缩放和平移的调整 vpt[4] = -interpolatedX * currentScaleFactor + adjustX vpt[5] = -interpolatedY * currentScaleFactor + adjustY } else { // 只有缩放,保持中心点 vpt[4] = currentVpt[4] * currentScaleFactor + adjustX vpt[5] = currentVpt[5] * currentScaleFactor + adjustY } // 更新缩放值显示 this.currentZoom.value = Math.round(transform.zoom * 100) this.canvas.renderAll() }, onComplete: () => { this._combinedAnimation = null this._zoomAnimation = null this._panAnimation = null this._wasPanning = false this._wasZooming = false // 确保最终状态准确 this._applyZoom(point, targetZoom, true) } }) } /** * 清理资源 */ dispose() { if (this._zoomAnimation) { this._zoomAnimation.kill() this._zoomAnimation = null } if (this._panAnimation) { this._panAnimation.kill() this._panAnimation = null } this._wheelEvents = [] this.canvas = null this.currentZoom = null } }