保存一下
This commit is contained in:
@@ -1,327 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,8 +30,8 @@ interface IResizeCallbackData {
|
|||||||
direction: TResizeDirection;
|
direction: TResizeDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拖拽参数 */
|
/** 拖拽/调整尺寸 参数 */
|
||||||
interface IDraggableOptions {
|
interface IDraggableResizableOptions {
|
||||||
/** 拖拽/调整尺寸目标元素 */
|
/** 拖拽/调整尺寸目标元素 */
|
||||||
target: HTMLElement;
|
target: HTMLElement;
|
||||||
/** 拖拽句柄 */
|
/** 拖拽句柄 */
|
||||||
@@ -128,7 +128,7 @@ export class DraggableResizable {
|
|||||||
private onResizeMove?: (data: IResizeCallbackData) => void;
|
private onResizeMove?: (data: IResizeCallbackData) => void;
|
||||||
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||||
|
|
||||||
constructor(options: IDraggableOptions) {
|
constructor(options: IDraggableResizableOptions) {
|
||||||
// Drag
|
// Drag
|
||||||
this.handle = options.handle;
|
this.handle = options.handle;
|
||||||
this.target = options.target;
|
this.target = options.target;
|
||||||
|
|||||||
700
src/core/utils/DraggableResizableWindow.ts
Normal file
700
src/core/utils/DraggableResizableWindow.ts
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
/** 拖拽移动开始的回调 */
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** 窗口状态 */
|
||||||
|
type WindowState = 'default' | 'minimized' | 'maximized';
|
||||||
|
|
||||||
|
interface TaskbarOptions {
|
||||||
|
/** 任务栏图标 DOM 元素或目标位置 {x, y} */
|
||||||
|
element?: HTMLElement;
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IElementRect {
|
||||||
|
/** 宽度 */
|
||||||
|
width: number;
|
||||||
|
/** 高度 */
|
||||||
|
height: number;
|
||||||
|
/** 顶点坐标(相对 offsetParent) */
|
||||||
|
top: number;
|
||||||
|
/** 左点坐标(相对 offsetParent) */
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽调整尺寸回调数据 */
|
||||||
|
interface IResizeCallbackData {
|
||||||
|
/** 宽度 */
|
||||||
|
width: number;
|
||||||
|
/** 高度 */
|
||||||
|
height: number;
|
||||||
|
/** 顶点坐标(相对 offsetParent) */
|
||||||
|
top: number;
|
||||||
|
/** 左点坐标(相对 offsetParent) */
|
||||||
|
left: number;
|
||||||
|
/** 拖拽调整尺寸的方向 */
|
||||||
|
direction: TResizeDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽参数 */
|
||||||
|
interface IDraggableResizableOptions {
|
||||||
|
/** 拖拽/调整尺寸目标元素 */
|
||||||
|
target: HTMLElement;
|
||||||
|
/** 拖拽句柄 */
|
||||||
|
handle?: HTMLElement;
|
||||||
|
/** 拖拽模式 */
|
||||||
|
mode?: 'transform' | 'position';
|
||||||
|
/** 拖拽边界或容器元素 */
|
||||||
|
boundary?: IBoundaryRect | HTMLElement;
|
||||||
|
/** 移动步进(网格吸附) */
|
||||||
|
snapGrid?: number;
|
||||||
|
/** 关键点吸附阈值 */
|
||||||
|
snapThreshold?: number;
|
||||||
|
/** 是否开启吸附动画 */
|
||||||
|
snapAnimation?: boolean;
|
||||||
|
/** 拖拽结束吸附动画时长 */
|
||||||
|
snapAnimationDuration?: number;
|
||||||
|
/** 是否允许超出边界 */
|
||||||
|
allowOverflow?: boolean;
|
||||||
|
|
||||||
|
/** 拖拽开始回调 */
|
||||||
|
onDragStart?: TDragStartCallback;
|
||||||
|
/** 拖拽移动中的回调 */
|
||||||
|
onDragMove?: TDragMoveCallback;
|
||||||
|
/** 拖拽结束回调 */
|
||||||
|
onDragEnd?: TDragEndCallback;
|
||||||
|
|
||||||
|
/** 调整尺寸的最小宽度 */
|
||||||
|
minWidth?: number;
|
||||||
|
/** 调整尺寸的最小高度 */
|
||||||
|
minHeight?: number;
|
||||||
|
/** 调整尺寸的最大宽度 */
|
||||||
|
maxWidth?: number;
|
||||||
|
/** 调整尺寸的最大高度 */
|
||||||
|
maxHeight?: number;
|
||||||
|
|
||||||
|
/** 拖拽调整尺寸中的回调 */
|
||||||
|
onResizeMove?: (data: IResizeCallbackData) => void;
|
||||||
|
/** 拖拽调整尺寸结束回调 */
|
||||||
|
onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽的范围边界 */
|
||||||
|
interface IBoundaryRect {
|
||||||
|
/** 最小 X 坐标 */
|
||||||
|
minX?: number;
|
||||||
|
/** 最大 X 坐标 */
|
||||||
|
maxX?: number;
|
||||||
|
/** 最小 Y 坐标 */
|
||||||
|
minY?: number;
|
||||||
|
/** 最大 Y 坐标 */
|
||||||
|
maxY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽 + 调整尺寸通用类
|
||||||
|
*/
|
||||||
|
export class DraggableResizableWindow {
|
||||||
|
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 onDragStart?: TDragStartCallback;
|
||||||
|
private onDragMove?: TDragMoveCallback;
|
||||||
|
private onDragEnd?: 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;
|
||||||
|
|
||||||
|
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 onResizeMove?: (data: IResizeCallbackData) => void;
|
||||||
|
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||||
|
|
||||||
|
private state: WindowState = 'default';
|
||||||
|
/** 目标元素默认 bounds */
|
||||||
|
private targetDefaultBounds: IElementRect;
|
||||||
|
/** 最大化前保存 bounds */
|
||||||
|
private maximizedBounds?: IElementRect;
|
||||||
|
/** 任务栏相关配置 */
|
||||||
|
private taskbar?: TaskbarOptions;
|
||||||
|
|
||||||
|
constructor(options: IDraggableResizableOptions & { taskbar?: TaskbarOptions }) {
|
||||||
|
// 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.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.onResizeMove = options.onResizeMove;
|
||||||
|
this.onResizeEnd = options.onResizeEnd;
|
||||||
|
|
||||||
|
this.targetDefaultBounds = { width: this.target.offsetWidth, height: this.target.offsetHeight, top: this.target.offsetTop, left: this.target.offsetLeft };
|
||||||
|
this.taskbar = options.taskbar;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化事件 */
|
||||||
|
private init() {
|
||||||
|
if (this.handle) {
|
||||||
|
this.handle.addEventListener('mousedown', this.onMouseDownDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听目标 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 onMouseDownDrag = (e: MouseEvent) => {
|
||||||
|
if (this.getResizeDirection(e)) return; // 避免和 resize 冲突
|
||||||
|
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();
|
||||||
|
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.onMouseMoveDrag);
|
||||||
|
document.addEventListener('mouseup', this.onMouseUpDrag);
|
||||||
|
|
||||||
|
this.onDragStart?.(this.offsetX, this.offsetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseMoveDrag = (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.onDragMove?.(newX, newY);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMouseUpDrag = () => {
|
||||||
|
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.onDragEnd?.(snapped.x, snapped.y);
|
||||||
|
this.updateDefaultBounds(snapped.x, snapped.y);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.applyPosition(snapped.x, snapped.y, true);
|
||||||
|
this.onDragEnd?.(snapped.x, snapped.y);
|
||||||
|
this.updateDefaultBounds(snapped.x, snapped.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', this.onMouseMoveDrag);
|
||||||
|
document.removeEventListener('mouseup', this.onMouseUpDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 onMouseDownResize = (e: MouseEvent) => {
|
||||||
|
const dir = this.getResizeDirection(e);
|
||||||
|
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 - parentRect.top;
|
||||||
|
this.startLeft = rect.left - parentRect.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.onResizeMove?.({
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
top: newTop,
|
||||||
|
left: newLeft,
|
||||||
|
direction: this.currentDirection,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 - parentRect.top,
|
||||||
|
left: rect.left - parentRect.left,
|
||||||
|
direction: this.currentDirection,
|
||||||
|
});
|
||||||
|
this.updateDefaultBounds(rect.left - parentRect.left, rect.top - parentRect.top, rect.width, rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentDirection = null;
|
||||||
|
this.updateCursor(null);
|
||||||
|
document.removeEventListener('mousemove', this.onResizeDrag);
|
||||||
|
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<TResizeDirection, string> = {
|
||||||
|
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() {
|
||||||
|
// 拖拽解绑:只在 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.onDocumentMouseMoveCursor);
|
||||||
|
document.removeEventListener('mousemove', this.onMouseMoveDrag);
|
||||||
|
document.removeEventListener('mouseup', this.onMouseUpDrag);
|
||||||
|
document.removeEventListener('mousemove', this.onResizeDrag);
|
||||||
|
document.removeEventListener('mouseup', this.onResizeEndHandler);
|
||||||
|
|
||||||
|
// 观察器清理
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最小化到任务栏 */
|
||||||
|
public minimize() {
|
||||||
|
if (this.state === 'minimized') return;
|
||||||
|
this.state = 'minimized';
|
||||||
|
|
||||||
|
// 获取目标任务栏位置
|
||||||
|
let targetX = 50, targetY = window.innerHeight - 40;
|
||||||
|
if (this.taskbar?.element) {
|
||||||
|
const rect = this.taskbar.element.getBoundingClientRect();
|
||||||
|
targetX = rect.left;
|
||||||
|
targetY = rect.top;
|
||||||
|
} else if (this.taskbar?.position) {
|
||||||
|
targetX = this.taskbar.position.x;
|
||||||
|
targetY = this.taskbar.position.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animateTo(targetX, targetY, 300, () => {
|
||||||
|
this.target.style.width = '0px';
|
||||||
|
this.target.style.height = '0px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最大化 */
|
||||||
|
public maximize() {
|
||||||
|
if (this.state === 'maximized') return;
|
||||||
|
this.state = 'maximized';
|
||||||
|
|
||||||
|
const rect = this.target.getBoundingClientRect();
|
||||||
|
this.targetDefaultBounds = { width: rect.width, height: rect.height, top: rect.top, left: rect.left };
|
||||||
|
this.maximizedBounds = { ...this.targetDefaultBounds };
|
||||||
|
|
||||||
|
const width = this.containerRect?.width ?? window.innerWidth;
|
||||||
|
const height = this.containerRect?.height ?? window.innerHeight;
|
||||||
|
|
||||||
|
this.target.style.width = `${width}px`;
|
||||||
|
this.target.style.height = `${height}px`;
|
||||||
|
this.applyPosition(0, 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复到默认状态 */
|
||||||
|
public restore(withAnimation = true) {
|
||||||
|
if (this.state === 'default') return;
|
||||||
|
this.state = 'default';
|
||||||
|
const b = this.targetDefaultBounds;
|
||||||
|
|
||||||
|
if (withAnimation) {
|
||||||
|
// 从当前位置(可能是任务栏位置或 0 尺寸)动画过渡到 targetDefaultBounds
|
||||||
|
const startWidth = this.target.offsetWidth || 0;
|
||||||
|
const startHeight = this.target.offsetHeight || 0;
|
||||||
|
const startLeft = this.currentX;
|
||||||
|
const startTop = this.currentY;
|
||||||
|
|
||||||
|
const deltaX = b.left - startLeft;
|
||||||
|
const deltaY = b.top - startTop;
|
||||||
|
const deltaW = b.width - startWidth;
|
||||||
|
const deltaH = b.height - startHeight;
|
||||||
|
const duration = 300;
|
||||||
|
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 = startLeft + deltaX * ease;
|
||||||
|
const y = startTop + deltaY * ease;
|
||||||
|
const w = startWidth + deltaW * ease;
|
||||||
|
const h = startHeight + deltaH * ease;
|
||||||
|
|
||||||
|
this.target.style.width = `${w}px`;
|
||||||
|
this.target.style.height = `${h}px`;
|
||||||
|
this.applyPosition(x, y, false);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
// 最终值
|
||||||
|
this.target.style.width = `${b.width}px`;
|
||||||
|
this.target.style.height = `${b.height}px`;
|
||||||
|
this.applyPosition(b.left, b.top, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
// 不动画直接恢复
|
||||||
|
this.target.style.width = `${b.width}px`;
|
||||||
|
this.target.style.height = `${b.height}px`;
|
||||||
|
this.applyPosition(b.left, b.top, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新默认 bounds */
|
||||||
|
private updateDefaultBounds(x?: number, y?: number, width?: number, height?: number) {
|
||||||
|
const rect = this.target.getBoundingClientRect();
|
||||||
|
this.targetDefaultBounds = {
|
||||||
|
left: x ?? rect.left,
|
||||||
|
top: y ?? rect.top,
|
||||||
|
width: width ?? rect.width,
|
||||||
|
height: height ?? rect.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
type ResizeDirection =
|
|
||||||
| 'top'
|
|
||||||
| 'bottom'
|
|
||||||
| 'left'
|
|
||||||
| 'right'
|
|
||||||
| 'top-left'
|
|
||||||
| 'top-right'
|
|
||||||
| 'bottom-left'
|
|
||||||
| 'bottom-right';
|
|
||||||
|
|
||||||
interface ResizeCallbackData {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
direction: ResizeDirection;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResizableOptions {
|
|
||||||
target: HTMLElement; // 要改变尺寸的元素
|
|
||||||
minWidth?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
onResize?: (data: ResizeCallbackData) => void; // 拖拽中回调
|
|
||||||
onResizeEnd?: (data: ResizeCallbackData) => void; // 拖拽结束回调
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Resizable {
|
|
||||||
private target: HTMLElement;
|
|
||||||
private minWidth: number;
|
|
||||||
private minHeight: number;
|
|
||||||
private maxWidth: number;
|
|
||||||
private maxHeight: number;
|
|
||||||
private currentDirection: ResizeDirection | null = null;
|
|
||||||
private startX = 0;
|
|
||||||
private startY = 0;
|
|
||||||
private startWidth = 0;
|
|
||||||
private startHeight = 0;
|
|
||||||
private startTop = 0;
|
|
||||||
private startLeft = 0;
|
|
||||||
private onResize?: (data: ResizeCallbackData) => void;
|
|
||||||
private onResizeEnd?: (data: ResizeCallbackData) => void;
|
|
||||||
|
|
||||||
constructor(options: ResizableOptions) {
|
|
||||||
this.target = options.target;
|
|
||||||
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;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
this.target.style.position = 'absolute';
|
|
||||||
this.target.addEventListener('mousedown', this.onMouseDown);
|
|
||||||
this.target.addEventListener('mousemove', this.onMouseMove);
|
|
||||||
this.target.addEventListener('mouseleave', this.onMouseLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDirection(e: MouseEvent): ResizeDirection | 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: ResizeDirection | null) {
|
|
||||||
if (!direction) {
|
|
||||||
this.target.style.cursor = 'default';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorMap: Record<ResizeDirection, string> = {
|
|
||||||
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 onMouseMove = (e: MouseEvent) => {
|
|
||||||
if (this.currentDirection) return;
|
|
||||||
const dir = this.getDirection(e);
|
|
||||||
this.updateCursor(dir);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onMouseLeave = () => {
|
|
||||||
if (!this.currentDirection) this.updateCursor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onMouseDown = (e: MouseEvent) => {
|
|
||||||
const dir = this.getDirection(e);
|
|
||||||
if (!dir) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
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.onMouseDrag);
|
|
||||||
document.addEventListener('mouseup', this.onMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onMouseDrag = (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 onMouseUp = () => {
|
|
||||||
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.onMouseDrag);
|
|
||||||
document.removeEventListener('mouseup', this.onMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
this.target.removeEventListener('mousedown', this.onMouseDown);
|
|
||||||
this.target.removeEventListener('mousemove', this.onMouseMove);
|
|
||||||
this.target.removeEventListener('mouseleave', this.onMouseLeave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,8 @@ import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
|||||||
import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts'
|
import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts'
|
||||||
import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
||||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
import { Draggable } from '@/core/utils/Draggable.ts'
|
|
||||||
import { Resizable } from '@/core/utils/Resizable.ts'
|
|
||||||
import { DraggableResizable } from '@/core/utils/DraggableResizable.ts'
|
import { DraggableResizable } from '@/core/utils/DraggableResizable.ts'
|
||||||
|
import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts'
|
||||||
|
|
||||||
export default class WindowFormImpl implements IWindowForm {
|
export default class WindowFormImpl implements IWindowForm {
|
||||||
private readonly _id: string = uuidV4();
|
private readonly _id: string = uuidV4();
|
||||||
@@ -53,13 +52,35 @@ export default class WindowFormImpl implements IWindowForm {
|
|||||||
div.style.height = '20px';
|
div.style.height = '20px';
|
||||||
div.style.backgroundColor = 'red';
|
div.style.backgroundColor = 'red';
|
||||||
dom.appendChild(div)
|
dom.appendChild(div)
|
||||||
|
const bt1 = document.createElement('button');
|
||||||
|
bt1.innerText = '最小化';
|
||||||
|
bt1.addEventListener('click', () => {
|
||||||
|
win.minimize();
|
||||||
|
})
|
||||||
|
div.appendChild(bt1)
|
||||||
|
const bt2 = document.createElement('button');
|
||||||
|
bt2.innerText = '最大化';
|
||||||
|
bt2.addEventListener('click', () => {
|
||||||
|
win.maximize();
|
||||||
|
})
|
||||||
|
div.appendChild(bt2)
|
||||||
|
const bt3 = document.createElement('button');
|
||||||
|
bt3.innerText = '关闭';
|
||||||
|
bt3.addEventListener('click', () => {
|
||||||
|
this.desktopRootDom.removeChild(dom)
|
||||||
|
win.destroy();
|
||||||
|
this.proc?.windowForms.delete(this.id);
|
||||||
|
processManager.removeProcess(this.proc!)
|
||||||
|
})
|
||||||
|
div.appendChild(bt3)
|
||||||
|
|
||||||
new DraggableResizable({
|
const win = new DraggableResizableWindow({
|
||||||
target: dom,
|
target: dom,
|
||||||
handle: div,
|
handle: div,
|
||||||
mode: 'position',
|
mode: 'position',
|
||||||
snapThreshold: 20,
|
snapThreshold: 20,
|
||||||
boundary: document.body,
|
boundary: document.body,
|
||||||
|
taskbar: { position: { x: 50, y: window.innerHeight - 40 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
this.desktopRootDom.appendChild(dom);
|
this.desktopRootDom.appendChild(dom);
|
||||||
|
|||||||
Reference in New Issue
Block a user