Files
vue-desktop/src/core/utils/Draggable.ts
2025-09-02 11:14:38 +08:00

328 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}