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`
${this.title}
`; } return html`
this.focused = true}>
${this.title}
${this.minimizable ? html`` : null} ${this.maximizable ? html`` : null} ${this.closable ? html`` : null}
${this.resizable ? html`
this.startResize('t', e)}>
this.startResize('b', e)}>
this.startResize('r', e)}>
this.startResize('l', e)}>
this.startResize('tr', e)}>
this.startResize('tl', e)}>
this.startResize('br', e)}>
this.startResize('bl', e)}>
` : null}
`; } } declare global { interface HTMLElementTagNameMap { 'window-form-element': WindowFormElement; } }