diff --git a/src/core/utils/DraggableResizable.ts b/src/core/utils/DraggableResizable.ts index 492cbd0..dda6e4d 100644 --- a/src/core/utils/DraggableResizable.ts +++ b/src/core/utils/DraggableResizable.ts @@ -22,9 +22,9 @@ interface IResizeCallbackData { width: number; /** 高度 */ height: number; - /** 顶点坐标 */ + /** 顶点坐标(相对 offsetParent) */ top: number; - /** 底点坐标 */ + /** 左点坐标(相对 offsetParent) */ left: number; /** 拖拽调整尺寸的方向 */ direction: TResizeDirection; @@ -52,11 +52,11 @@ interface IDraggableOptions { allowOverflow?: boolean; /** 拖拽开始回调 */ - onStart?: TDragStartCallback; + onDragStart?: TDragStartCallback; /** 拖拽移动中的回调 */ - onMove?: TDragMoveCallback; + onDragMove?: TDragMoveCallback; /** 拖拽结束回调 */ - onEnd?: TDragEndCallback; + onDragEnd?: TDragEndCallback; /** 调整尺寸的最小宽度 */ minWidth?: number; @@ -67,30 +67,28 @@ interface IDraggableOptions { /** 调整尺寸的最大高度 */ maxHeight?: number; - /** 拖拽调整尺寸回调 */ - onResize?: (data: IResizeCallbackData) => void; + /** 拖拽调整尺寸中的回调 */ + onResizeMove?: (data: IResizeCallbackData) => void; /** 拖拽调整尺寸结束回调 */ onResizeEnd?: (data: IResizeCallbackData) => void; } /** 拖拽的范围边界 */ interface IBoundaryRect { - /** 最小 X 坐标 */ - minX?: number; - /** 最大 X 坐标 */ - maxX?: number; - /** 最小 Y 坐标 */ - minY?: number; - /** 最大 Y 坐标 */ - maxY?: number; + /** 最小 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; @@ -101,9 +99,9 @@ export class DraggableResizable { private snapAnimationDuration: number; private allowOverflow: boolean; - private onStart?: TDragStartCallback; - private onMove?: TDragMoveCallback; - private onEnd?: TDragEndCallback; + private onDragStart?: TDragStartCallback; + private onDragMove?: TDragMoveCallback; + private onDragEnd?: TDragEndCallback; private isDragging = false; private startX = 0; @@ -116,11 +114,8 @@ export class DraggableResizable { private containerRect?: DOMRect; private resizeObserver?: ResizeObserver; private mutationObserver: MutationObserver; - private animationFrame?: number; - // ---------------- Resize 属性 ---------------- - private currentDirection: TResizeDirection | null = null; private startWidth = 0; private startHeight = 0; @@ -130,7 +125,7 @@ export class DraggableResizable { private minHeight: number; private maxWidth: number; private maxHeight: number; - private onResize?: (data: IResizeCallbackData) => void; + private onResizeMove?: (data: IResizeCallbackData) => void; private onResizeEnd?: (data: IResizeCallbackData) => void; constructor(options: IDraggableOptions) { @@ -145,57 +140,54 @@ export class DraggableResizable { this.snapAnimationDuration = options.snapAnimationDuration ?? 200; this.allowOverflow = options.allowOverflow ?? true; - this.onStart = options.onStart; - this.onMove = options.onMove; - this.onEnd = options.onEnd; + this.onDragStart = options.onDragStart; + this.onDragMove = options.onDragMove; + this.onDragEnd = options.onDragEnd; // 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.onResizeMove = options.onResizeMove; 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.handle.addEventListener('mousedown', this.onMouseDownDrag); } - this.target.addEventListener('mousedown', this.onMouseDown); - this.target.addEventListener('mousemove', this.onMouseMoveCursor); + + this.target.addEventListener('mousedown', this.onMouseDownResize); this.target.addEventListener('mouseleave', this.onMouseLeave); + document.addEventListener('mousemove', this.onDocumentMouseMoveCursor); if (this.boundary instanceof HTMLElement) { this.observeResize(this.boundary); } - // 确保 target 是 absolute 或 relative - if (getComputedStyle(this.target).position === 'static') { - this.target.style.position = 'absolute'; + // 监听目标 DOM 是否被移除,自动销毁 + this.mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + mutation.removedNodes.forEach((node) => { + if (node === this.target) { + this.destroy(); + } + }); + } + }); + if (this.target.parentElement) { + this.mutationObserver.observe(this.target.parentElement, { childList: true }); } } - 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 onMouseDownDrag = (e: MouseEvent) => { + if (this.getResizeDirection(e)) return; // 避免和 resize 冲突 + e.preventDefault(); + this.startDrag(e); }; private startDrag(e: MouseEvent) { @@ -205,20 +197,21 @@ export class DraggableResizable { if (this.mode === 'position') { const rect = this.target.getBoundingClientRect(); - this.offsetX = rect.left; - this.offsetY = rect.top; + const parentRect = this.target.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }; + this.offsetX = rect.left - parentRect.left; + this.offsetY = rect.top - parentRect.top; } else { this.offsetX = this.currentX; this.offsetY = this.currentY; } - document.addEventListener('mousemove', this.onDragMove); - document.addEventListener('mouseup', this.onDragEnd); + document.addEventListener('mousemove', this.onMouseMoveDrag); + document.addEventListener('mouseup', this.onMouseUpDrag); - this.onStart?.(this.offsetX, this.offsetY); + this.onDragStart?.(this.offsetX, this.offsetY); } - private onDragMove = (e: MouseEvent) => { + private onMouseMoveDrag = (e: MouseEvent) => { if (!this.isDragging) return; const dx = e.clientX - this.startX; @@ -233,10 +226,10 @@ export class DraggableResizable { } this.applyPosition(newX, newY, false); - this.onMove?.(newX, newY); + this.onDragMove?.(newX, newY); }; - private onDragEnd = () => { + private onMouseUpDrag = () => { if (!this.isDragging) return; this.isDragging = false; @@ -244,15 +237,15 @@ export class DraggableResizable { if (this.snapAnimation) { this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => { - this.onEnd?.(snapped.x, snapped.y); + this.onDragEnd?.(snapped.x, snapped.y); }); } else { this.applyPosition(snapped.x, snapped.y, true); - this.onEnd?.(snapped.x, snapped.y); + this.onDragEnd?.(snapped.x, snapped.y); } - document.removeEventListener('mousemove', this.onDragMove); - document.removeEventListener('mouseup', this.onDragEnd); + document.removeEventListener('mousemove', this.onMouseMoveDrag); + document.removeEventListener('mouseup', this.onMouseUpDrag); }; private applyPosition(x: number, y: number, isFinal: boolean) { @@ -269,177 +262,24 @@ export class DraggableResizable { 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; + private onMouseDownResize = (e: MouseEvent) => { const dir = this.getResizeDirection(e); - this.updateCursor(dir); - }; - - private onMouseLeave = () => { - if (!this.currentDirection && !this.isDragging) this.updateCursor(null); + if (!dir) return; + e.preventDefault(); + this.startResize(e, dir); }; private startResize(e: MouseEvent, dir: TResizeDirection) { this.currentDirection = dir; const rect = this.target.getBoundingClientRect(); + const parentRect = this.target.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }; + this.startX = e.clientX; this.startY = e.clientY; this.startWidth = rect.width; this.startHeight = rect.height; - this.startTop = rect.top; - this.startLeft = rect.left; + this.startTop = rect.top - parentRect.top; + this.startLeft = rect.left - parentRect.left; document.addEventListener('mousemove', this.onResizeDrag); document.addEventListener('mouseup', this.onResizeEndHandler); @@ -501,7 +341,7 @@ export class DraggableResizable { this.target.style.top = `${newTop}px`; this.target.style.left = `${newLeft}px`; - this.onResize?.({ + this.onResizeMove?.({ width: newWidth, height: newHeight, top: newTop, @@ -513,11 +353,12 @@ export class DraggableResizable { private onResizeEndHandler = () => { if (this.currentDirection) { const rect = this.target.getBoundingClientRect(); + const parentRect = this.target.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }; this.onResizeEnd?.({ width: rect.width, height: rect.height, - top: rect.top, - left: rect.left, + top: rect.top - parentRect.top, + left: rect.left - parentRect.left, direction: this.currentDirection, }); } @@ -528,20 +369,191 @@ export class DraggableResizable { document.removeEventListener('mouseup', this.onResizeEndHandler); }; - /** 销毁 */ + 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; + + 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 onDocumentMouseMoveCursor = (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 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.onDragMove?.(x, y); + + if (progress < 1) { + this.animationFrame = requestAnimationFrame(step); + } else { + this.applyPosition(targetX, targetY, true); + this.onDragMove?.(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) { + let { x: snappedX, y: snappedY } = { x, y }; + + // 1. 容器吸附 + const containerSnap = this.getSnapPoints(); + if (this.snapThreshold > 0) { + for (const sx of containerSnap.x) { + if (Math.abs(x - sx) <= this.snapThreshold) { + snappedX = sx; + break; + } + } + for (const sy of containerSnap.y) { + if (Math.abs(y - sy) <= this.snapThreshold) { + snappedY = sy; + break; + } + } + } + + // 2. 窗口吸附 TODO + + return { x: snappedX, y: snappedY }; + } + + private getSnapPoints() { + const snapPoints = { x: [] as number[], y: [] as number[] }; + + if (this.boundary instanceof HTMLElement && this.containerRect) { + const rect = this.target.getBoundingClientRect(); + snapPoints.x = [0, this.containerRect.width - rect.width]; + snapPoints.y = [0, this.containerRect.height - rect.height]; + } else if (!(this.boundary instanceof HTMLElement) && this.boundary) { + if (this.boundary.minX !== undefined) snapPoints.x.push(this.boundary.minX); + if (this.boundary.maxX !== undefined) snapPoints.x.push(this.boundary.maxX); + if (this.boundary.minY !== undefined) snapPoints.y.push(this.boundary.minY); + if (this.boundary.maxY !== undefined) snapPoints.y.push(this.boundary.maxY); + } + + return snapPoints; + } + + private observeResize(container: HTMLElement) { + if (this.resizeObserver) this.resizeObserver.disconnect(); + this.resizeObserver = new ResizeObserver(() => { + this.containerRect = container.getBoundingClientRect(); + this.applyBoundary(); + }); + this.resizeObserver.observe(container); + this.containerRect = container.getBoundingClientRect(); + } + + /** 销毁实例 */ public destroy() { - if (this.handle) this.handle.removeEventListener('mousedown', this.onMouseDown); - this.target.removeEventListener('mousedown', this.onMouseDown); - this.target.removeEventListener('mousemove', this.onMouseMoveCursor); + // 拖拽解绑:只在 handle 上解绑 + if (this.handle) { + this.handle.removeEventListener('mousedown', this.onMouseDownDrag); + } + + // 调整尺寸解绑 + this.target.removeEventListener('mousedown', this.onMouseDownResize); this.target.removeEventListener('mouseleave', this.onMouseLeave); - document.removeEventListener('mousemove', this.onDragMove); - document.removeEventListener('mouseup', this.onDragEnd); + // 全局事件解绑 + document.removeEventListener('mousemove', this.onDocumentMouseMoveCursor); + document.removeEventListener('mousemove', this.onMouseMoveDrag); + document.removeEventListener('mouseup', this.onMouseUpDrag); document.removeEventListener('mousemove', this.onResizeDrag); document.removeEventListener('mouseup', this.onResizeEndHandler); - this.resizeObserver?.disconnect(); - this.mutationObserver.disconnect(); + // 观察器清理 + if (this.resizeObserver) this.resizeObserver.disconnect(); + if (this.mutationObserver) this.mutationObserver.disconnect(); if (this.animationFrame) cancelAnimationFrame(this.animationFrame); + + // 所有属性置空,释放内存 + Object.keys(this).forEach(k => (this as any)[k] = null); } }