/** 拖拽移动开始的回调 */ type TDragStartCallback = (x: number, y: number) => void; /** 拖拽移动中的回调 */ type TDragMoveCallback = (x: number, y: number) => void; /** 拖拽移动结束的回调 */ type TDragEndCallback = (x: number, y: number) => void; /** 拖拽调整尺寸的方向 */ type TResizeDirection = | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; /** 拖拽调整尺寸回调数据 */ interface IResizeCallbackData { /** 宽度 */ width: number; /** 高度 */ height: number; /** 顶点坐标 */ top: number; /** 底点坐标 */ left: number; /** 拖拽调整尺寸的方向 */ direction: TResizeDirection; } /** 拖拽参数 */ interface IDraggableOptions { /** 拖拽/调整尺寸目标元素 */ target: HTMLElement; /** 拖拽句柄 */ handle?: HTMLElement; /** 拖拽模式 */ mode?: 'transform' | 'position'; /** 拖拽边界或容器元素 */ boundary?: IBoundaryRect | HTMLElement; /** 移动步进(网格吸附) */ snapGrid?: number; /** 关键点吸附阈值 */ snapThreshold?: number; /** 是否开启吸附动画 */ snapAnimation?: boolean; /** 拖拽结束吸附动画时长 */ snapAnimationDuration?: number; /** 是否允许超出边界 */ allowOverflow?: boolean; /** 拖拽开始回调 */ onStart?: TDragStartCallback; /** 拖拽移动中的回调 */ onMove?: TDragMoveCallback; /** 拖拽结束回调 */ onEnd?: TDragEndCallback; /** 调整尺寸的最小宽度 */ minWidth?: number; /** 调整尺寸的最小高度 */ minHeight?: number; /** 调整尺寸的最大宽度 */ maxWidth?: number; /** 调整尺寸的最大高度 */ maxHeight?: number; /** 拖拽调整尺寸回调 */ onResize?: (data: IResizeCallbackData) => void; /** 拖拽调整尺寸结束回调 */ onResizeEnd?: (data: IResizeCallbackData) => void; } /** 拖拽的范围边界 */ interface IBoundaryRect { /** 最小 X 坐标 */ minX?: number; /** 最大 X 坐标 */ maxX?: number; /** 最小 Y 坐标 */ minY?: number; /** 最大 Y 坐标 */ maxY?: number; } /** * 拖拽 + 调整尺寸通用类 */ export class DraggableResizable { // ---------------- Drag 属性 ---------------- private handle?: HTMLElement; private target: HTMLElement; private boundary?: HTMLElement | IBoundaryRect; private mode: 'transform' | 'position'; private snapGrid: number; private snapThreshold: number; private snapAnimation: boolean; private snapAnimationDuration: number; private allowOverflow: boolean; private onStart?: TDragStartCallback; private onMove?: TDragMoveCallback; private onEnd?: TDragEndCallback; 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; // ---------------- Resize 属性 ---------------- private currentDirection: TResizeDirection | null = null; private startWidth = 0; private startHeight = 0; private startTop = 0; private startLeft = 0; private minWidth: number; private minHeight: number; private maxWidth: number; private maxHeight: number; private onResize?: (data: IResizeCallbackData) => void; private onResizeEnd?: (data: IResizeCallbackData) => void; constructor(options: IDraggableOptions) { // Drag 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; // Resize this.minWidth = options.minWidth ?? 100; this.minHeight = options.minHeight ?? 50; this.maxWidth = options.maxWidth ?? window.innerWidth; this.maxHeight = options.maxHeight ?? window.innerHeight; this.onResize = options.onResize; this.onResizeEnd = options.onResizeEnd; // 自动监听 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() { if (this.handle) { this.handle.addEventListener('mousedown', this.onMouseDown); } this.target.addEventListener('mousedown', this.onMouseDown); this.target.addEventListener('mousemove', this.onMouseMoveCursor); this.target.addEventListener('mouseleave', this.onMouseLeave); if (this.boundary instanceof HTMLElement) { this.observeResize(this.boundary); } // 确保 target 是 absolute 或 relative if (getComputedStyle(this.target).position === 'static') { this.target.style.position = 'absolute'; } } private onMouseDown = (e: MouseEvent) => { const dir = this.getResizeDirection(e); if (dir) { // 开始 Resize e.preventDefault(); this.startResize(e, dir); } else { // 开始 Drag e.preventDefault(); this.startDrag(e); } }; private startDrag(e: MouseEvent) { 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.onDragMove); document.addEventListener('mouseup', this.onDragEnd); this.onStart?.(this.offsetX, this.offsetY); } private onDragMove = (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 onDragEnd = () => { 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.onDragMove); document.removeEventListener('mouseup', this.onDragEnd); }; 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); 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) { 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() { 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, containerRect.width - targetRect.width); snapY.push(0, containerRect.height - targetRect.height); } 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 }; } private observeResize(element: HTMLElement) { this.resizeObserver = new ResizeObserver(() => { this.containerRect = element.getBoundingClientRect(); if (!this.allowOverflow) this.applyBoundary(); }); this.resizeObserver.observe(element); this.containerRect = element.getBoundingClientRect(); } private getResizeDirection(e: MouseEvent): TResizeDirection | null { const rect = this.target.getBoundingClientRect(); const offset = 8; const x = e.clientX; const y = e.clientY; const top = y >= rect.top && y <= rect.top + offset; const bottom = y >= rect.bottom - offset && y <= rect.bottom; const left = x >= rect.left && x <= rect.left + offset; const right = x >= rect.right - offset && x <= rect.right; // console.log('resize', top, bottom, left, right) if (top && left) return 'top-left'; if (top && right) return 'top-right'; if (bottom && left) return 'bottom-left'; if (bottom && right) return 'bottom-right'; if (top) return 'top'; if (bottom) return 'bottom'; if (left) return 'left'; if (right) return 'right'; return null; } private updateCursor(direction: TResizeDirection | null) { if (!direction) { this.target.style.cursor = 'default'; return; } const cursorMap: Record = { top: 'ns-resize', bottom: 'ns-resize', left: 'ew-resize', right: 'ew-resize', 'top-left': 'nwse-resize', 'top-right': 'nesw-resize', 'bottom-left': 'nesw-resize', 'bottom-right': 'nwse-resize', }; this.target.style.cursor = cursorMap[direction]; } private onMouseMoveCursor = (e: MouseEvent) => { if (this.currentDirection || this.isDragging) return; const dir = this.getResizeDirection(e); this.updateCursor(dir); }; private onMouseLeave = () => { if (!this.currentDirection && !this.isDragging) this.updateCursor(null); }; private startResize(e: MouseEvent, dir: TResizeDirection) { this.currentDirection = dir; const rect = this.target.getBoundingClientRect(); this.startX = e.clientX; this.startY = e.clientY; this.startWidth = rect.width; this.startHeight = rect.height; this.startTop = rect.top; this.startLeft = rect.left; document.addEventListener('mousemove', this.onResizeDrag); document.addEventListener('mouseup', this.onResizeEndHandler); } private onResizeDrag = (e: MouseEvent) => { if (!this.currentDirection) return; let deltaX = e.clientX - this.startX; let deltaY = e.clientY - this.startY; let newWidth = this.startWidth; let newHeight = this.startHeight; let newTop = this.startTop; let newLeft = this.startLeft; switch (this.currentDirection) { case 'right': newWidth = this.startWidth + deltaX; break; case 'bottom': newHeight = this.startHeight + deltaY; break; case 'bottom-right': newWidth = this.startWidth + deltaX; newHeight = this.startHeight + deltaY; break; case 'left': newWidth = this.startWidth - deltaX; newLeft = this.startLeft + deltaX; break; case 'top': newHeight = this.startHeight - deltaY; newTop = this.startTop + deltaY; break; case 'top-left': newWidth = this.startWidth - deltaX; newLeft = this.startLeft + deltaX; newHeight = this.startHeight - deltaY; newTop = this.startTop + deltaY; break; case 'top-right': newWidth = this.startWidth + deltaX; newHeight = this.startHeight - deltaY; newTop = this.startTop + deltaY; break; case 'bottom-left': newWidth = this.startWidth - deltaX; newLeft = this.startLeft + deltaX; newHeight = this.startHeight + deltaY; break; } newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth)); newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight)); this.target.style.width = `${newWidth}px`; this.target.style.height = `${newHeight}px`; this.target.style.top = `${newTop}px`; this.target.style.left = `${newLeft}px`; this.onResize?.({ width: newWidth, height: newHeight, top: newTop, left: newLeft, direction: this.currentDirection, }); }; private onResizeEndHandler = () => { if (this.currentDirection) { const rect = this.target.getBoundingClientRect(); this.onResizeEnd?.({ width: rect.width, height: rect.height, top: rect.top, left: rect.left, direction: this.currentDirection, }); } this.currentDirection = null; this.updateCursor(null); document.removeEventListener('mousemove', this.onResizeDrag); document.removeEventListener('mouseup', this.onResizeEndHandler); }; /** 销毁 */ public destroy() { if (this.handle) this.handle.removeEventListener('mousedown', this.onMouseDown); this.target.removeEventListener('mousedown', this.onMouseDown); this.target.removeEventListener('mousemove', this.onMouseMoveCursor); this.target.removeEventListener('mouseleave', this.onMouseLeave); document.removeEventListener('mousemove', this.onDragMove); document.removeEventListener('mouseup', this.onDragEnd); document.removeEventListener('mousemove', this.onResizeDrag); document.removeEventListener('mouseup', this.onResizeEndHandler); this.resizeObserver?.disconnect(); this.mutationObserver.disconnect(); if (this.animationFrame) cancelAnimationFrame(this.animationFrame); } }