200 lines
5.5 KiB
TypeScript
200 lines
5.5 KiB
TypeScript
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?: IBoundary | HTMLElement;
|
|
/** 拖拽开始回调 */
|
|
onStart?: TDragStartCallback;
|
|
/** 拖拽移动中回调 */
|
|
onMove?: TDragMoveCallback;
|
|
/** 拖拽结束回调 */
|
|
onEnd?: TDragEndCallback;
|
|
}
|
|
|
|
/** 拖拽的范围边界 */
|
|
interface IBoundary {
|
|
/** 最小 X 坐标 */
|
|
minX?: number;
|
|
/** 最大 X 坐标 */
|
|
maxX?: number;
|
|
/** 最小 Y 坐标 */
|
|
minY?: number;
|
|
/** 最大 Y 坐标 */
|
|
maxY?: number;
|
|
}
|
|
|
|
/**
|
|
* 拖拽功能通用类
|
|
*/
|
|
export class Draggable {
|
|
private handle: HTMLElement;
|
|
private target: HTMLElement;
|
|
private mode: 'transform' | 'position';
|
|
private boundary?: IBoundary;
|
|
private containerElement?: HTMLElement;
|
|
private containerBounds?: IBoundary;
|
|
|
|
private startX = 0;
|
|
private startY = 0;
|
|
private originX = 0;
|
|
private originY = 0;
|
|
private currentX = 0;
|
|
private currentY = 0;
|
|
private dragging = false;
|
|
|
|
private onStart?: TDragStartCallback;
|
|
private onMove?: TDragMoveCallback;
|
|
private onEnd?: TDragEndCallback;
|
|
|
|
private resizeObserver?: ResizeObserver;
|
|
|
|
constructor(options: IDraggableOptions) {
|
|
this.handle = options.handle;
|
|
this.target = options.target;
|
|
this.mode = options.mode ?? 'transform';
|
|
this.onStart = options.onStart;
|
|
this.onMove = options.onMove;
|
|
this.onEnd = options.onEnd;
|
|
|
|
// 判断 boundary 类型
|
|
if (options.boundary instanceof HTMLElement) {
|
|
this.containerElement = options.boundary;
|
|
this.observeResize(); // 监听容器和目标变化
|
|
} else {
|
|
this.boundary = options.boundary;
|
|
}
|
|
|
|
if (this.mode === 'position') {
|
|
const computed = window.getComputedStyle(this.target);
|
|
if (computed.position === 'static') {
|
|
this.target.style.position = 'absolute';
|
|
}
|
|
}
|
|
|
|
this.handle.addEventListener('mousedown', this.onMouseDown);
|
|
}
|
|
|
|
/** 监听容器和目标大小变化 */
|
|
private observeResize() {
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.updateContainerBounds();
|
|
});
|
|
|
|
if (this.containerElement) {
|
|
this.resizeObserver.observe(this.containerElement);
|
|
}
|
|
|
|
// 监听目标大小变化
|
|
this.resizeObserver.observe(this.target);
|
|
|
|
this.updateContainerBounds();
|
|
}
|
|
|
|
/** 更新边界 */
|
|
private updateContainerBounds() {
|
|
if (!this.containerElement) return;
|
|
|
|
const containerRect = this.containerElement.getBoundingClientRect();
|
|
const targetRect = this.target.getBoundingClientRect();
|
|
|
|
if (this.mode === 'transform') {
|
|
this.containerBounds = {
|
|
minX: containerRect.left + window.scrollX,
|
|
maxX: containerRect.right + window.scrollX - targetRect.width,
|
|
minY: containerRect.top + window.scrollY,
|
|
maxY: containerRect.bottom + window.scrollY - targetRect.height,
|
|
};
|
|
} else {
|
|
this.containerBounds = {
|
|
minX: 0,
|
|
minY: 0,
|
|
maxX: containerRect.width - targetRect.width,
|
|
maxY: containerRect.height - targetRect.height,
|
|
};
|
|
}
|
|
}
|
|
|
|
private onMouseDown = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
this.handle.style.cursor = 'move';
|
|
this.dragging = true;
|
|
|
|
const rect = this.target.getBoundingClientRect();
|
|
this.originX = rect.left + window.scrollX;
|
|
this.originY = rect.top + window.scrollY;
|
|
|
|
if (this.mode === 'position') {
|
|
const style = window.getComputedStyle(this.target);
|
|
this.originX = parseFloat(style.left) || 0;
|
|
this.originY = parseFloat(style.top) || 0;
|
|
}
|
|
|
|
this.startX = e.clientX;
|
|
this.startY = e.clientY;
|
|
|
|
this.currentX = this.originX;
|
|
this.currentY = this.originY;
|
|
|
|
this.onStart?.(this.currentX, this.currentY);
|
|
|
|
document.addEventListener('mousemove', this.onMouseMove);
|
|
document.addEventListener('mouseup', this.onMouseUp);
|
|
};
|
|
|
|
private onMouseMove = (e: MouseEvent) => {
|
|
if (!this.dragging) return;
|
|
|
|
let newX = this.originX + (e.clientX - this.startX);
|
|
let newY = this.originY + (e.clientY - this.startY);
|
|
|
|
const bounds = this.containerBounds || this.boundary;
|
|
if (bounds) {
|
|
if (bounds.minX !== undefined) newX = Math.max(newX, bounds.minX);
|
|
if (bounds.maxX !== undefined) newX = Math.min(newX, bounds.maxX);
|
|
if (bounds.minY !== undefined) newY = Math.max(newY, bounds.minY);
|
|
if (bounds.maxY !== undefined) newY = Math.min(newY, bounds.maxY);
|
|
}
|
|
|
|
if (this.mode === 'transform') {
|
|
this.target.style.transform = `translate(${newX}px, ${newY}px)`;
|
|
} else {
|
|
this.target.style.left = `${newX}px`;
|
|
this.target.style.top = `${newY}px`;
|
|
}
|
|
|
|
this.currentX = newX;
|
|
this.currentY = newY;
|
|
|
|
this.onMove?.(this.currentX, this.currentY);
|
|
};
|
|
|
|
private onMouseUp = () => {
|
|
this.dragging = false;
|
|
this.handle.style.cursor = 'default';
|
|
|
|
this.onEnd?.(this.currentX, this.currentY);
|
|
|
|
document.removeEventListener('mousemove', this.onMouseMove);
|
|
document.removeEventListener('mouseup', this.onMouseUp);
|
|
};
|
|
|
|
public destroy() {
|
|
this.handle.removeEventListener('mousedown', this.onMouseDown);
|
|
document.removeEventListener('mousemove', this.onMouseMove);
|
|
document.removeEventListener('mouseup', this.onMouseUp);
|
|
this.resizeObserver?.disconnect();
|
|
}
|
|
}
|
|
|