diff --git a/src/core/utils/DraggableResizableWindow.ts b/src/core/utils/DraggableResizableWindow.ts index 3993ebf..2e2be6e 100644 --- a/src/core/utils/DraggableResizableWindow.ts +++ b/src/core/utils/DraggableResizableWindow.ts @@ -379,7 +379,7 @@ export class DraggableResizableWindow { this.onDragMove?.(x, y); if (progress < 1) this.animationFrame = requestAnimationFrame(step); - else { this.applyPosition(targetX, targetY, true); this.onDragMove?.(targetX, targetY); onComplete?.(); } + else { this.applyPosition(targetX, targetY, true); onComplete?.(); } }; this.animationFrame = requestAnimationFrame(step); } @@ -483,24 +483,26 @@ export class DraggableResizableWindow { case 'bottom-left': newWidth -= dx; newX += dx; newHeight += dy; break; } - newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth)); - newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight)); - - this.applyResizeBounds(newX, newY, newWidth, newHeight); + const d = this.applyResizeBounds(newX, newY, newWidth, newHeight); this.updateCursor(this.currentDirection); this.onResizeMove?.({ - width: newWidth, - height: newHeight, - left: newX, - top: newY, + width: d.width, + height: d.height, + left: d.left, + top: d.top, direction: this.currentDirection, }); } // 应用尺寸调整边界 - private applyResizeBounds(newX: number, newY: number, newWidth: number, newHeight: number) { + private applyResizeBounds(newX: number, newY: number, newWidth: number, newHeight: number): { + left: number; + top: number; + width: number; + height: number; + } { // 最小/最大宽高限制 newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth)); newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight)); @@ -512,7 +514,12 @@ export class DraggableResizableWindow { this.target.style.width = `${newWidth}px`; this.target.style.height = `${newHeight}px`; this.applyPosition(newX, newY, false); - return; + return { + left: newX, + top: newY, + width: newWidth, + height: newHeight, + } } newX = Math.min(Math.max(0, newX), this.containerRect.width - newWidth); @@ -523,6 +530,12 @@ export class DraggableResizableWindow { this.target.style.width = `${newWidth}px`; this.target.style.height = `${newHeight}px`; this.applyPosition(newX, newY, false); + return { + left: newX, + top: newY, + width: newWidth, + height: newHeight, + } } private onResizeEndHandler = (e?: MouseEvent) => { diff --git a/src/core/window/impl/WindowFormImpl.ts b/src/core/window/impl/WindowFormImpl.ts index 21f5aa4..efba156 100644 --- a/src/core/window/impl/WindowFormImpl.ts +++ b/src/core/window/impl/WindowFormImpl.ts @@ -7,6 +7,8 @@ import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts' import { processManager } from '@/core/process/ProcessManager.ts' import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts' import '../css/window-form.scss' +import { serviceManager } from '@/core/service/kernel/ServiceManager.ts' +import '../ui/WindowFormElement.ts' export default class WindowFormImpl implements IWindowForm { private readonly _id: string = uuidV4(); @@ -26,6 +28,9 @@ export default class WindowFormImpl implements IWindowForm { private get desktopRootDom() { return XSystem.instance.desktopRootDom; } + private get sm() { + return serviceManager.getService('WindowForm') + } public get windowFormEle() { return this.dom; } @@ -71,7 +76,14 @@ export default class WindowFormImpl implements IWindowForm { }, }) - this.desktopRootDom.appendChild(this.dom); + // this.desktopRootDom.appendChild(this.dom); + const wf = document.createElement('window-form-element') + wf.dragContainer = document.body + wf.snapDistance = 20 + wf.addEventListener('onDragStart', (e) => { + console.log('dragstart') + }) + this.desktopRootDom.appendChild(wf) } public closeWindowForm() { diff --git a/src/core/window/ui/WindowFormElement.ts b/src/core/window/ui/WindowFormElement.ts new file mode 100644 index 0000000..793b019 --- /dev/null +++ b/src/core/window/ui/WindowFormElement.ts @@ -0,0 +1,592 @@ +import { LitElement, html, css, unsafeCSS } from 'lit' +import { customElement, property } from 'lit/decorators.js'; +import wfStyle from './wf.scss?inline' +import type { TWindowFormState } from '@/core/window/types/WindowFormTypes.ts' + +/** 拖拽移动开始的回调 */ +type TDragStartCallback = (x: number, y: number) => void; +/** 拖拽移动中的回调 */ +type TDragMoveCallback = (x: number, y: number) => void; +/** 拖拽移动结束的回调 */ +type TDragEndCallback = (x: number, y: number) => void; + +/** 拖拽调整尺寸的方向 */ +type TResizeDirection = + | 't' // 上 + | 'b' // 下 + | 'l' // 左 + | 'r' // 右 + | 'tl' // 左上 + | 'tr' // 右上 + | 'bl' // 左下 + | 'br'; // 右下 + +/** 拖拽调整尺寸回调数据 */ +interface IResizeCallbackData { + /** 宽度 */ + width: number; + /** 高度 */ + height: number; + /** 顶点坐标(相对 offsetParent) */ + top: number; + /** 左点坐标(相对 offsetParent) */ + left: number; + /** 拖拽调整尺寸的方向 */ + direction: TResizeDirection | null; +} + +/** 元素边界 */ +interface IElementRect { + /** 宽度 */ + width: number; + /** 高度 */ + height: number; + /** 顶点坐标(相对 offsetParent) */ + top: number; + /** 左点坐标(相对 offsetParent) */ + left: number; +} + +@customElement('window-form-element') +export class WindowFormElement extends LitElement { + // ==== 公共属性 ==== + @property({ type: String }) override title = 'Window'; + @property({ type: Boolean }) resizable = true; + @property({ type: Boolean }) minimizable = true; + @property({ type: Boolean }) maximizable = true; + @property({ type: Boolean }) closable = true; + @property({ type: Boolean, reflect: true }) focused = false; + @property({ type: Object }) dragContainer?: HTMLElement; + @property({ type: Boolean }) allowOverflow = true; // 允许窗口超出容器 + @property({ type: Number }) snapDistance = 0; // 吸附距离 + @property({ type: Boolean }) snapAnimation = true; // 吸附动画 + @property({ type: Number }) snapAnimationDuration = 300; // 吸附动画时长 ms + @property({ type: Number }) maxWidth?: number; + @property({ type: Number }) minWidth?: number; + @property({ type: Number }) maxHeight?: number; + @property({ type: Number }) minHeight?: number; + @property({ type: String }) taskbarElementId?: string; + + // ==== 拖拽/缩放状态(内部变量,不触发渲染) ==== + private dragging = false; + private resizeDir: TResizeDirection | null = null; + private startX = 0; + private startY = 0; + private startWidth = 0; + private startHeight = 0; + private startX_host = 0; + private startY_host = 0; + + private x = 0; + private y = 0; + private preX = 0; + private preY = 0; + private width = 640; + private height = 360; + private animationFrame?: number; + + private minimized = false; + private maximized = false; + private windowFormState: TWindowFormState = 'default'; + /** 元素信息 */ + private targetBounds: IElementRect; + /** 最小化前的元素信息 */ + private targetPreMinimizeBounds?: IElementRect; + /** 最大化前的元素信息 */ + private targetPreMaximizedBounds?: IElementRect; + + static override styles = css`${unsafeCSS(wfStyle)}`; + + override firstUpdated() { + window.addEventListener('pointerup', this.onPointerUp); + window.addEventListener('pointermove', this.onPointerMove); + this.addEventListener('pointerdown', () => this.bringToFront()); + + const container = this.dragContainer || document.body; + const containerRect = container.getBoundingClientRect(); + this.x = containerRect.width / 2 - this.width / 2; + this.y = containerRect.height / 2 - this.height / 2; + this.style.width = `${this.width}px`; + this.style.height = `${this.height}px`; + this.style.transform = `translate(${this.x}px, ${this.y}px)`; + + this.targetBounds = { + width: this.offsetWidth, + height: this.offsetHeight, + top: this.x, + left: this.y, + }; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('pointerup', this.onPointerUp); + window.removeEventListener('pointermove', this.onPointerMove); + } + + // 窗口聚焦、置顶 + private bringToFront() { + this.focused = true; + } + + // ====== 拖拽 ====== + private onTitlePointerDown = (e: PointerEvent) => { + if (e.pointerType === 'mouse' && e.button !== 0) return; + if ((e.target as HTMLElement).closest('.controls')) return; + + e.preventDefault(); + + this.dragging = true; + this.startX = e.clientX; + this.startY = e.clientY; + this.preX = this.x; + this.preY = this.y; + this.setPointerCapture?.(e.pointerId); + + this.dispatchEvent(new CustomEvent('windowForm:dragStart', { + detail: { x: this.x, y: this.y }, + bubbles: true, + composed: true + })); + }; + + private onPointerMove = (e: PointerEvent) => { + if (this.dragging) { + const dx = e.clientX - this.startX; + const dy = e.clientY - this.startY; + + const x = this.preX + dx; + const y = this.preY + dy; + + this.applyPosition(x, y, false); + this.dispatchEvent(new CustomEvent('windowForm:dragMove', { + detail: { x, y }, + bubbles: true, + composed: true + })); + } else if (this.resizeDir) { + this.performResize(e); + } + }; + + private onPointerUp = (e: PointerEvent) => { + if (this.dragging) { + this.dragUp(e) + } + if (this.resizeDir) { + this.resizeUp(e); + } + this.dragging = false; + this.resizeDir = null; + try { this.releasePointerCapture?.(e.pointerId); } catch {} + }; + + /** 获取所有吸附点 */ + private getSnapPoints() { + const snapPoints = { x: [] as number[], y: [] as number[] }; + const containerRect = (this.dragContainer || document.body).getBoundingClientRect(); + const rect = this.getBoundingClientRect(); + snapPoints.x = [0, containerRect.width - rect.width]; + snapPoints.y = [0, containerRect.height - rect.height]; + return snapPoints; + } + + /** + * 获取最近的吸附点 + * @param x 左上角起始点x + * @param y 左上角起始点y + */ + private applySnapping(x: number, y: number) { + let snappedX = x, snappedY = y; + const containerSnap = this.getSnapPoints(); + if (this.snapDistance > 0) { + for (const sx of containerSnap.x) if (Math.abs(x - sx) <= this.snapDistance) { snappedX = sx; break; } + for (const sy of containerSnap.y) if (Math.abs(y - sy) <= this.snapDistance) { snappedY = sy; break; } + } + return { x: snappedX, y: snappedY }; + } + + private dragUp(e: PointerEvent) { + const snapped = this.applySnapping(this.x, this.y); + if (this.snapAnimation) { + this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => { + this.updateTargetBounds(snapped.x, snapped.y); + this.dispatchEvent(new CustomEvent('windowForm:dragEnd', { + detail: { x: snapped.x, y: snapped.y }, + bubbles: true, + composed: true + })); + }); + } else { + this.applyPosition(snapped.x, snapped.y, true); + this.updateTargetBounds(snapped.x, snapped.y); + this.dispatchEvent(new CustomEvent('windowForm:dragEnd', { + detail: { x: snapped.x, y: snapped.y }, + bubbles: true, + composed: true + })); + } + } + + private applyPosition(x: number, y: number, isFinal: boolean) { + this.x = x; + this.y = y; + this.style.transform = `translate(${x}px, ${y}px)`; + if (isFinal) this.applyBoundary(); + } + + private applyBoundary() { + if (this.allowOverflow) return; + let { x, y } = { x: this.x, y: this.y }; + + const rect = this.getBoundingClientRect(); + const containerRect = (this.dragContainer || document.body).getBoundingClientRect(); + x = Math.min(Math.max(x, 0), containerRect.width - rect.width); + y = Math.min(Math.max(y, 0), containerRect.height - rect.height); + + this.applyPosition(x, y, false); + } + + private animateTo(targetX: number, targetY: number, duration: number, onComplete?: () => void) { + if (this.animationFrame) cancelAnimationFrame(this.animationFrame); + const startX = this.x; + const startY = this.y; + 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.dispatchEvent(new CustomEvent('windowForm:dragMove', { + detail: { x, y }, + bubbles: true, + composed: true + })); + + if (progress < 1) { + this.animationFrame = requestAnimationFrame(step); + } else { + this.applyPosition(targetX, targetY, true); + onComplete?.(); + } + }; + this.animationFrame = requestAnimationFrame(step); + } + + // ====== 缩放 ====== + private startResize = (dir: TResizeDirection, e: PointerEvent) => { + if (!this.resizable) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + + e.preventDefault(); + e.stopPropagation(); + this.resizeDir = dir; + this.startX = e.clientX; + this.startY = e.clientY; + + const rect = this.getBoundingClientRect(); + this.startWidth = rect.width; + this.startHeight = rect.height; + this.startX_host = rect.left; + this.startY_host = rect.top; + + this.setPointerCapture?.(e.pointerId); + this.dispatchEvent(new CustomEvent('windowForm:resizeStart', { + detail: { x: this.x, y: this.y, width: this.width, height: this.height, dir }, + bubbles: true, + composed: true + })); + }; + + private performResize(e: PointerEvent) { + if (!this.resizeDir) return; + + let newWidth = this.startWidth; + let newHeight = this.startHeight; + let newX = this.startX_host; + let newY = this.startY_host; + + const dx = e.clientX - this.startX; + const dy = e.clientY - this.startY; + + switch (this.resizeDir) { + case 'r': newWidth += dx; break; + case 'b': newHeight += dy; break; + case 'l': newWidth -= dx; newX += dx; break; + case 't': newHeight -= dy; newY += dy; break; + case 'tl': newWidth -= dx; newX += dx; newHeight -= dy; newY += dy; break; + case 'tr': newWidth += dx; newHeight -= dy; newY += dy; break; + case 'bl': newWidth -= dx; newX += dx; newHeight += dy; break; + case 'br': newWidth += dx; newHeight += dy; break; + } + + const d = this.applyResizeBounds(newX, newY, newWidth, newHeight); + + this.dispatchEvent(new CustomEvent('windowForm:resizeMove', { + detail: { dir: this.resizeDir, width: d.width, height: d.height, left: d.left, top: d.top }, + bubbles: true, + composed: true + })); + } + + /** + * 应用尺寸调整边界 + * @param newX 新的X坐标 + * @param newY 新的Y坐标 + * @param newWidth 新的宽度 + * @param newHeight 新的高度 + * @private + */ + private applyResizeBounds(newX: number, newY: number, newWidth: number, newHeight: number): { + left: number; + top: number; + width: number; + height: number; + } { + // 最小/最大宽高限制 + if (this.minWidth != null) newWidth = Math.max(this.minWidth, newWidth); + if (this.maxWidth != null) newWidth = Math.min(this.maxWidth, newWidth); + if (this.minHeight != null) newHeight = Math.max(this.minHeight, newHeight); + if (this.maxHeight != null) newHeight = Math.min(this.maxHeight, newHeight); + + // 边界限制 + if (this.allowOverflow) { + this.x = newX; + this.y = newY; + this.width = newWidth; + this.height = newHeight; + this.style.width = `${newWidth}px`; + this.style.height = `${newHeight}px`; + this.applyPosition(newX, newY, false); + + return { + left: newX, + top: newY, + width: newWidth, + height: newHeight, + } + } + + const containerRect = (this.dragContainer || document.body).getBoundingClientRect(); + newX = Math.min(Math.max(0, newX), containerRect.width - newWidth); + newY = Math.min(Math.max(0, newY), containerRect.height - newHeight); + + this.x = newX; + this.y = newY; + this.width = newWidth; + this.height = newHeight; + this.style.width = `${newWidth}px`; + this.style.height = `${newHeight}px`; + this.applyPosition(newX, newY, false); + + return { + left: newX, + top: newY, + width: newWidth, + height: newHeight, + } + } + + private resizeUp(e: PointerEvent) { + if (!this.resizable) return; + + this.updateTargetBounds(this.x, this.y, this.width, this.height); + this.dispatchEvent(new CustomEvent('windowForm:resizeEnd', { + detail: { dir: this.resizeDir, width: this.width, height: this.height, left: this.x, top: this.y }, + bubbles: true, + composed: true + })); + } + + // ====== 窗口操作 ====== + // 最小化到任务栏 + private minimize() { + if (!this.taskbarElementId) return; + if (this.windowFormState === 'minimized') return; + this.targetPreMinimizeBounds = { ...this.targetBounds } + this.windowFormState = 'minimized'; + + const taskbar = document.querySelector(this.taskbarElementId); + if (!taskbar) throw new Error('任务栏元素未找到'); + + const rect = taskbar.getBoundingClientRect(); + const startX = this.x; + const startY = this.y; + const startW = this.offsetWidth; + const startH = this.offsetHeight; + + this.animateWindow(startX, startY, startW, startH, rect.left, rect.top, rect.width, rect.height, 400, () => { + this.style.display = 'none'; + }); + } + + /** 最大化 */ + private maximize() { + if (this.windowFormState === 'maximized') { + this.restore(); + return; + } + this.targetPreMaximizedBounds = { ...this.targetBounds } + this.windowFormState = 'maximized'; + + const rect = this.getBoundingClientRect(); + + const startX = this.x; + const startY = this.y; + const startW = rect.width; + const startH = rect.height; + + const targetX = 0; + const targetY = 0; + const containerRect = (this.dragContainer || document.body).getBoundingClientRect(); + const targetW = containerRect?.width ?? window.innerWidth; + const targetH = containerRect?.height ?? window.innerHeight; + + this.animateWindow(startX, startY, startW, startH, targetX, targetY, targetW, targetH, 300); + } + + /** 恢复到默认窗体状态 */ + private restore(onComplete?: () => void) { + console.log(11) + if (this.windowFormState === 'default') return; + let b: IElementRect; + if ((this.windowFormState as TWindowFormState) === 'minimized' && this.targetPreMinimizeBounds) { + // 最小化恢复,恢复到最小化前的状态 + b = this.targetPreMinimizeBounds; + } else if ((this.windowFormState as TWindowFormState) === 'maximized' && this.targetPreMaximizedBounds) { + // 最大化恢复,恢复到最大化前的默认状态 + b = this.targetPreMaximizedBounds; + } else { + b = this.targetBounds; + } + + this.windowFormState = 'default'; + + this.style.display = 'block'; + + const startX = this.x; + const startY = this.y; + const startW = this.offsetWidth; + const startH = this.offsetHeight; + + this.animateWindow(startX, startY, startW, startH, b.left, b.top, b.width, b.height, 300, onComplete); + } + + /** + * 窗体最大化、最小化和恢复默认 动画 + * @param startX + * @param startY + * @param startW + * @param startH + * @param targetX + * @param targetY + * @param targetW + * @param targetH + * @param duration + * @param onComplete + * @private + */ + private animateWindow( + startX: number, + startY: number, + startW: number, + startH: number, + targetX: number, + targetY: number, + targetW: number, + targetH: number, + duration: number, + onComplete?: () => void + ) { + 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 + (targetX - startX) * ease; + const y = startY + (targetY - startY) * ease; + const w = startW + (targetW - startW) * ease; + const h = startH + (targetH - startH) * ease; + + this.style.width = `${w}px`; + this.style.height = `${h}px`; + this.applyPosition(x, y, false); + + if (progress < 1) { + requestAnimationFrame(step); + } else { + this.style.width = `${targetW}px`; + this.style.height = `${targetH}px`; + this.applyPosition(targetX, targetY, true); + onComplete?.(); + this.dispatchEvent(new CustomEvent('windowForm:stateChange', { + detail: { state: this.windowFormState }, + bubbles: true, + composed: true + })); + } + }; + requestAnimationFrame(step); + } + + private updateTargetBounds(left: number, top: number, width?: number, height?: number) { + this.targetBounds = { + left, top, + width: width ?? this.offsetWidth, + height: height ?? this.offsetHeight + }; + } + + // ====== 渲染 ====== + override render() { + if (this.minimized) { + return html` +