import { gsap } from "gsap"; /** * 画布动画管理器 * 负责处理画布平移、缩放等动画效果 */ export class AnimationManager { /** * 创建动画管理器 * @param {fabric.Canvas} canvas fabric.js画布实例 * @param {Object} options 配置选项 */ constructor(canvas, options = {}) { this.canvasManager = options.canvasManager; this.canvas = canvas; this.currentZoom = options.currentZoom; // 动画相关属性 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) { const canvasViewWidth = this.canvasManager.canvasViewWidth; const canvasViewHeight = this.canvasManager.canvasViewHeight; const canvasWidth = this.canvasManager.canvasWidth; const canvasHeight = this.canvasManager.canvasHeight; const panX = canvasViewWidth / 2 - canvasWidth / 2 const panY = canvasViewHeight / 2 - canvasHeight / 2 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: panX, panY: panY, 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, panX, panY]); this.currentZoom.value = 100; this._zoomAnimation = null; this._panAnimation = null; resolve(); }, }); } else { this.canvas.setViewportTransform([1, 0, 0, 1, panX, panY]); 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; } }