diff --git a/src/core/desktop/ui/DesktopComponent.vue b/src/core/desktop/ui/DesktopComponent.vue index cb16caf..62f2eb2 100644 --- a/src/core/desktop/ui/DesktopComponent.vue +++ b/src/core/desktop/ui/DesktopComponent.vue @@ -11,7 +11,6 @@ :iconInfo="appIcon" :gridTemplate="gridTemplate" @dblclick="runApp(appIcon)" /> - />
diff --git a/src/core/utils/Draggable1.ts b/src/core/utils/Draggable1.ts deleted file mode 100644 index bd7e2d0..0000000 --- a/src/core/utils/Draggable1.ts +++ /dev/null @@ -1,297 +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; - snapAnimationDuration?: number; - onStart?: TDragStartCallback; - onMove?: TDragMoveCallback; - onEnd?: TDragEndCallback; -} - -interface IBoundaryRect { - minX?: number; - maxX?: number; - minY?: number; - maxY?: number; -} - -// 全局实例列表,用于窗口之间吸附 -const DraggableInstances: Draggable[] = []; - -export class Draggable { - 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 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 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 ?? 10; - this.snapAnimation = options.snapAnimation ?? true; - this.snapAnimationDuration = options.snapAnimationDuration ?? 200; - this.onStart = options.onStart; - this.onMove = options.onMove; - this.onEnd = options.onEnd; - - this.init(); - - // 注册全局实例 - DraggableInstances.push(this); - } - - /** 初始化 */ - 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); - - const index = DraggableInstances.indexOf(this); - if (index >= 0) DraggableInstances.splice(index, 1); - } - - /** 拖拽开始 */ - 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); - const ease = 1 - Math.pow(1 - progress, 3); // easeOutCubic - - 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) 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, containerRect.width - targetRect.width); - snapY.push(0, 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); - } - - // 窗口之间吸附 - for (const other of DraggableInstances) { - if (other === this) continue; - const rect = other.target.getBoundingClientRect(); - snapX.push(rect.left, rect.right, rect.left + rect.width / 2); - snapY.push(rect.top, rect.bottom, rect.top + rect.height / 2); - } - - 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(); - } -} diff --git a/src/core/utils/DraggableResizable.ts b/src/core/utils/DraggableResizable.ts new file mode 100644 index 0000000..16154bd --- /dev/null +++ b/src/core/utils/DraggableResizable.ts @@ -0,0 +1,527 @@ +type TDragStartCallback = (x: number, y: number) => void; +type TDragMoveCallback = (x: number, y: number) => void; +type TDragEndCallback = (x: number, y: number) => void; + +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 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: ResizeCallbackData) => void; + onResizeEnd?: (data: ResizeCallbackData) => void; +} + +/** 拖拽范围边界 */ +interface IBoundaryRect { + minX?: number; + maxX?: number; + minY?: number; + 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: ResizeDirection | 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: ResizeCallbackData) => void; + private onResizeEnd?: (data: ResizeCallbackData) => 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); + } else { + 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'; + } + } + + // ---------------- Drag 方法 ---------------- + 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(); + } + + // ---------------- Resize 方法 ---------------- + private getResizeDirection(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