type TDragStartCallback = (x: number, y: number) => void; type TDragMoveCallback = (x: number, y: number) => void; type TDragEndCallback = (x: number, y: number) => void; /** * 拖拽参数 */ interface IDraggableOptions { /** 用来触发拖拽的元素、句柄 */ handle: HTMLElement; /** 真正移动的元素 */ target: HTMLElement; /** 拖拽模式 */ mode?: 'transform' | 'position'; /** 拖拽的边界或容器元素 */ boundary?: IBoundaryRect | HTMLElement; /** 移动步进(网格吸附) */ snapGrid?: number; /** 关键点吸附(边界/中心等) */ snapThreshold?: number; /** 是否开启吸附动画(拖拽结束时) */ snapAnimation?: boolean; /** 拖拽结束时吸附动画时长(ms,默认 200) */ snapAnimationDuration?: number; /** 是否允许拖拽超出容器范围 */ allowOverflow?: boolean; /** 拖拽开始回调 */ onStart?: TDragStartCallback; /** 拖拽移动中回调 */ onMove?: TDragMoveCallback; /** 拖拽结束回调 */ onEnd?: TDragEndCallback; } /** 拖拽的范围边界 */ interface IBoundaryRect { /** 最小 X 坐标 */ minX?: number; /** 最大 X 坐标 */ maxX?: number; /** 最小 Y 坐标 */ minY?: number; /** 最大 Y 坐标 */ maxY?: number; } /** * 拖拽功能通用类 */ export class Draggable { private handle: HTMLElement; private target: HTMLElement; private boundary?: HTMLElement | IBoundaryRect; private mode: "transform" | "position"; private snapGrid: number; private snapThreshold: number; private onStart?: TDragStartCallback; private onMove?: TDragMoveCallback; private onEnd?: TDragEndCallback; private snapAnimation: boolean; private snapAnimationDuration: number; private allowOverflow: boolean; private isDragging = false; private startX = 0; private startY = 0; private offsetX = 0; private offsetY = 0; private currentX = 0; private currentY = 0; private containerRect?: DOMRect; private resizeObserver?: ResizeObserver; private mutationObserver: MutationObserver; /** 动画控制 */ private animationFrame?: number; constructor(options: IDraggableOptions) { this.handle = options.handle; this.target = options.target; this.boundary = options.boundary; this.mode = options.mode ?? "transform"; this.snapGrid = options.snapGrid ?? 1; this.snapThreshold = options.snapThreshold ?? 0; this.snapAnimation = options.snapAnimation ?? false; this.snapAnimationDuration = options.snapAnimationDuration ?? 200; this.allowOverflow = options.allowOverflow ?? true; this.onStart = options.onStart; this.onMove = options.onMove; this.onEnd = options.onEnd; // 自动监听 DOM 移除 this.mutationObserver = new MutationObserver(() => { if (!document.body.contains(this.target)) { this.destroy(); } }); this.mutationObserver.observe(document.body, { childList: true, subtree: true }); this.init(); } /** 初始化事件 */ private init() { this.handle.addEventListener("mousedown", this.onMouseDown); if (this.boundary instanceof HTMLElement) { this.observeResize(this.boundary); } } /** 销毁 */ public destroy() { this.handle.removeEventListener("mousedown", this.onMouseDown); document.removeEventListener("mousemove", this.onMouseMove); document.removeEventListener("mouseup", this.onMouseUp); this.resizeObserver?.disconnect(); if (this.animationFrame) cancelAnimationFrame(this.animationFrame); this.mutationObserver.disconnect(); } /** 开始拖拽 */ private onMouseDown = (e: MouseEvent) => { e.preventDefault(); this.isDragging = true; this.startX = e.clientX; this.startY = e.clientY; if (this.mode === "position") { const rect = this.target.getBoundingClientRect(); this.offsetX = rect.left; this.offsetY = rect.top; } else { this.offsetX = this.currentX; this.offsetY = this.currentY; } document.addEventListener("mousemove", this.onMouseMove); document.addEventListener("mouseup", this.onMouseUp); this.onStart?.(this.offsetX, this.offsetY); }; /** 拖拽中(实时移动,不做吸附) */ private onMouseMove = (e: MouseEvent) => { if (!this.isDragging) return; const dx = e.clientX - this.startX; const dy = e.clientY - this.startY; let newX = this.offsetX + dx; let newY = this.offsetY + dy; if (this.snapGrid > 1) { newX = Math.round(newX / this.snapGrid) * this.snapGrid; newY = Math.round(newY / this.snapGrid) * this.snapGrid; } this.applyPosition(newX, newY, false); this.onMove?.(newX, newY); }; /** 拖拽结束(此时再执行吸附动画) */ private onMouseUp = () => { if (!this.isDragging) return; this.isDragging = false; const snapped = this.applySnapping(this.currentX, this.currentY); if (this.snapAnimation) { // 使用动画过渡到吸附点 this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => { this.onEnd?.(snapped.x, snapped.y); }); } else { this.applyPosition(snapped.x, snapped.y, true); this.onEnd?.(snapped.x, snapped.y); } document.removeEventListener("mousemove", this.onMouseMove); document.removeEventListener("mouseup", this.onMouseUp); }; /** 应用位置 */ private applyPosition(x: number, y: number, isFinal: boolean) { this.currentX = x; this.currentY = y; if (this.mode === "position") { this.target.style.left = `${x}px`; this.target.style.top = `${y}px`; } else { this.target.style.transform = `translate(${x}px, ${y}px)`; } if (isFinal) { this.applyBoundary(); } } /** 平滑动画移动到目标位置 */ private animateTo(targetX: number, targetY: number, duration: number, onComplete?: () => void) { if (this.animationFrame) cancelAnimationFrame(this.animationFrame); const startX = this.currentX; const startY = this.currentY; const deltaX = targetX - startX; const deltaY = targetY - startY; const startTime = performance.now(); const step = (now: number) => { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // 缓动函数(easeOutCubic) const ease = 1 - Math.pow(1 - progress, 3); const x = startX + deltaX * ease; const y = startY + deltaY * ease; this.applyPosition(x, y, false); this.onMove?.(x, y); if (progress < 1) { this.animationFrame = requestAnimationFrame(step); } else { this.applyPosition(targetX, targetY, true); this.onMove?.(targetX, targetY); onComplete?.(); } }; this.animationFrame = requestAnimationFrame(step); } /** 应用边界限制 */ private applyBoundary() { if (!this.boundary || this.allowOverflow) return; let { x, y } = { x: this.currentX, y: this.currentY }; if (this.boundary instanceof HTMLElement && this.containerRect) { const rect = this.target.getBoundingClientRect(); const minX = 0; const minY = 0; const maxX = this.containerRect.width - rect.width; const maxY = this.containerRect.height - rect.height; x = Math.min(Math.max(x, minX), maxX); y = Math.min(Math.max(y, minY), maxY); } else if (!(this.boundary instanceof HTMLElement)) { if (this.boundary.minX !== undefined) x = Math.max(x, this.boundary.minX); if (this.boundary.maxX !== undefined) x = Math.min(x, this.boundary.maxX); if (this.boundary.minY !== undefined) y = Math.max(y, this.boundary.minY); if (this.boundary.maxY !== undefined) y = Math.min(y, this.boundary.maxY); } this.currentX = x; this.currentY = y; this.applyPosition(x, y, false); } /** 计算吸附点 */ private applySnapping(x: number, y: number): { x: number; y: number } { const snapPoints = this.getSnapPoints(); let snappedX = x; let snappedY = y; if (this.snapThreshold > 0) { for (const sx of snapPoints.x) { if (Math.abs(x - sx) <= this.snapThreshold) { snappedX = sx; break; } } for (const sy of snapPoints.y) { if (Math.abs(y - sy) <= this.snapThreshold) { snappedY = sy; break; } } } return { x: snappedX, y: snappedY }; } /** 获取吸附点 */ private getSnapPoints(): { x: number[]; y: number[] } { const snapX: number[] = []; const snapY: number[] = []; if (this.boundary instanceof HTMLElement && this.containerRect) { const containerRect = this.containerRect; const targetRect = this.target.getBoundingClientRect(); // 左右边界 snapX.push(0); snapX.push(containerRect.width - targetRect.width); // 上下边界 snapY.push(0); snapY.push(containerRect.height - targetRect.height); // 中心点 // snapX.push(containerRect.width / 2 - targetRect.width / 2); // snapY.push(containerRect.height / 2 - targetRect.height / 2); } else if (!(this.boundary instanceof HTMLElement)) { if (this.boundary?.minX !== undefined) snapX.push(this.boundary.minX); if (this.boundary?.maxX !== undefined) snapX.push(this.boundary.maxX); if (this.boundary?.minY !== undefined) snapY.push(this.boundary.minY); if (this.boundary?.maxY !== undefined) snapY.push(this.boundary.maxY); } return { x: snapX, y: snapY }; } /** 监听 boundary 尺寸变化 */ private observeResize(element: HTMLElement) { this.resizeObserver = new ResizeObserver(() => { this.containerRect = element.getBoundingClientRect(); }); this.resizeObserver.observe(element); this.containerRect = element.getBoundingClientRect(); } }