From 926ac698ccfb6bb2a63fc98dbb9afea90ce0fdd7 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Wed, 3 Sep 2025 10:23:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=B8=80=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/utils/Draggable.ts | 327 ---------- src/core/utils/DraggableResizable.ts | 6 +- src/core/utils/DraggableResizableWindow.ts | 700 +++++++++++++++++++++ src/core/utils/Resizable.ts | 224 ------- src/core/window/impl/WindowFormImpl.ts | 27 +- 5 files changed, 727 insertions(+), 557 deletions(-) delete mode 100644 src/core/utils/Draggable.ts create mode 100644 src/core/utils/DraggableResizableWindow.ts delete mode 100644 src/core/utils/Resizable.ts diff --git a/src/core/utils/Draggable.ts b/src/core/utils/Draggable.ts deleted file mode 100644 index 1cd82c7..0000000 --- a/src/core/utils/Draggable.ts +++ /dev/null @@ -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(); - } -} diff --git a/src/core/utils/DraggableResizable.ts b/src/core/utils/DraggableResizable.ts index dda6e4d..d7ba518 100644 --- a/src/core/utils/DraggableResizable.ts +++ b/src/core/utils/DraggableResizable.ts @@ -30,8 +30,8 @@ interface IResizeCallbackData { direction: TResizeDirection; } -/** 拖拽参数 */ -interface IDraggableOptions { +/** 拖拽/调整尺寸 参数 */ +interface IDraggableResizableOptions { /** 拖拽/调整尺寸目标元素 */ target: HTMLElement; /** 拖拽句柄 */ @@ -128,7 +128,7 @@ export class DraggableResizable { private onResizeMove?: (data: IResizeCallbackData) => void; private onResizeEnd?: (data: IResizeCallbackData) => void; - constructor(options: IDraggableOptions) { + constructor(options: IDraggableResizableOptions) { // Drag this.handle = options.handle; this.target = options.target; diff --git a/src/core/utils/DraggableResizableWindow.ts b/src/core/utils/DraggableResizableWindow.ts new file mode 100644 index 0000000..905b65e --- /dev/null +++ b/src/core/utils/DraggableResizableWindow.ts @@ -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 = { + 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, + }; + } +} diff --git a/src/core/utils/Resizable.ts b/src/core/utils/Resizable.ts deleted file mode 100644 index df38f83..0000000 --- a/src/core/utils/Resizable.ts +++ /dev/null @@ -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 = { - 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); - } -} diff --git a/src/core/window/impl/WindowFormImpl.ts b/src/core/window/impl/WindowFormImpl.ts index 55eeb47..5e40f28 100644 --- a/src/core/window/impl/WindowFormImpl.ts +++ b/src/core/window/impl/WindowFormImpl.ts @@ -5,9 +5,8 @@ import type { IWindowForm } from '@/core/window/IWindowForm.ts' import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts' import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.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 { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts' export default class WindowFormImpl implements IWindowForm { private readonly _id: string = uuidV4(); @@ -53,13 +52,35 @@ export default class WindowFormImpl implements IWindowForm { div.style.height = '20px'; div.style.backgroundColor = 'red'; 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, handle: div, mode: 'position', snapThreshold: 20, boundary: document.body, + taskbar: { position: { x: 50, y: window.innerHeight - 40 } }, }) this.desktopRootDom.appendChild(dom);