import { LitElement, html, css, unsafeCSS } from 'lit' import { customElement, property } from 'lit/decorators.js'; import wfStyle from './css/wf.scss?inline' type TWindowFormState = 'default' | 'minimized' | 'maximized'; /** 拖拽移动开始的回调 */ 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; /** x坐标 */ x: number; /** y坐标 */ y: number; } /** 窗口自定义事件 */ export interface WindowFormEventMap extends HTMLElementEventMap { 'windowForm:dragStart': CustomEvent; 'windowForm:dragMove': CustomEvent; 'windowForm:dragEnd': CustomEvent; 'windowForm:resizeStart': CustomEvent; 'windowForm:resizeMove': CustomEvent; 'windowForm:resizeEnd': CustomEvent; 'windowForm:stateChange': CustomEvent<{ state: TWindowFormState }>; 'windowForm:stateChange:minimize': CustomEvent<{ state: TWindowFormState }>; 'windowForm:stateChange:maximize': CustomEvent<{ state: TWindowFormState }>; 'windowForm:stateChange:restore': CustomEvent<{ state: TWindowFormState }>; 'windowForm:close': CustomEvent; 'windowForm:minimize': CustomEvent; } @customElement('window-form-element') export class WindowFormElement extends LitElement { // ==== 公共属性 ==== @property({ type: String }) wid: string @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: boolean = true @property({ type: String, reflect: true }) windowFormState: TWindowFormState = 'default' @property({ type: Object }) dragContainer?: HTMLElement // 元素的父容器 @property({ type: Number }) snapDistance = 0 // 吸附距离 @property({ type: Boolean }) snapAnimation = false // 吸附动画 @property({ type: Number }) snapAnimationDuration = 300 // 吸附动画时长 ms @property({ type: Number }) maxWidth?: number = Infinity @property({ type: Number }) minWidth?: number = 200 @property({ type: Number }) maxHeight?: number = Infinity @property({ type: Number }) minHeight?: number = 200 @property({ type: String }) taskbarElementId?: string @property({ type: Object }) wfData: any; private _listeners: Array<{ type: string; original: Function; wrapped: EventListener }> = [] // ==== 拖拽/缩放状态(内部变量,不触发渲染) ==== // 自身的x坐标 private x = 0 // 自身的y坐标 private y = 0 // 自身的宽度 private width = 640 // 自身的高度 private height = 360 // 记录拖拽开始时自身x坐标 private originalX = 0 // 记录拖拽开始时自身y坐标 private originalY = 0 // 鼠标开始拖拽时自身宽度 private originalWidth = 640 // 鼠标开始拖拽时高度 private originalHeight = 360 // 鼠标开始拖拽时x坐标 private pointStartX = 0 // 鼠标开始拖拽时x坐标 private pointStartY = 0 private animationFrame?: number // 是否拖拽状态 private dragging = false // 是否缩放状态 private resizing = false // 缩放方向 private resizeDir: TResizeDirection | null = null // private get x() { // return this.wfData.state.x // } // private set x(value: number) { // this.wfData.patch({ x: value }) // } // private get y() { // return this.wfData.state.y // } // private set y(value: number) { // this.wfData.patch({ y: value }) // } // private windowFormState: TWindowFormState = 'default'; /** 元素信息 */ private targetBounds: IElementRect /** 最小化前的元素信息 */ private targetPreMinimizeBounds?: IElementRect /** 最大化前的元素信息 */ private targetPreMaximizedBounds?: IElementRect static override styles = css` ${unsafeCSS(wfStyle)} ` protected override createRenderRoot() { const root = this.attachShadow({ mode: 'closed' }) const sheet = new CSSStyleSheet() sheet.replaceSync(wfStyle) root.adoptedStyleSheets = [sheet] return root } public addManagedEventListener( type: K, handler: (this: WindowFormElement, ev: WindowFormEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void public addManagedEventListener( type: K, handler: (ev: WindowFormEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void /** * 添加受管理的事件监听 * @param type 事件类型 * @param handler 事件处理函数 */ public addManagedEventListener( type: K, handler: | ((this: WindowFormElement, ev: WindowFormEventMap[K]) => any) | ((ev: WindowFormEventMap[K]) => any), options?: boolean | AddEventListenerOptions ) { const wrapped: EventListener = (ev: Event) => { (handler as any).call(this, ev as WindowFormEventMap[K]) } this.addEventListener(type, wrapped, options) this._listeners.push({ type, original: handler, wrapped }) } public removeManagedEventListener( type: K, handler: | ((this: WindowFormElement, ev: WindowFormEventMap[K]) => any) | ((ev: WindowFormEventMap[K]) => any) ) { const index = this._listeners.findIndex( l => l.type === type && l.original === handler ) if (index !== -1) { const { type: t, wrapped } = this._listeners[index] this.removeEventListener(t, wrapped) this._listeners.splice(index, 1) } } /** * 移除所有受管理事件监听 */ public removeAllManagedListeners() { for (const { type, wrapped } of this._listeners) { this.removeEventListener(type, wrapped) } this._listeners = [] } override firstUpdated() { const { width, height } = this.getBoundingClientRect() this.width = width || this.width this.height = height || this.height 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)` window.addEventListener('pointerup', this.onPointerUp) window.addEventListener('pointermove', this.onPointerMove) this.addEventListener('pointerdown', this.toggleFocus) this.targetBounds = { width: this.width, height: this.height, x: this.x, y: this.y, } } override disconnectedCallback() { super.disconnectedCallback() window.removeEventListener('pointerup', this.onPointerUp) window.removeEventListener('pointermove', this.onPointerMove) this.removeEventListener('pointerdown', this.toggleFocus) // wfem.removeEventListener('windowFormFocus', this.windowFormFocusFun) this.removeAllManagedListeners() } private windowFormFocusFun = (id: string) => { if (id === this.wid) { this.focused = true } else { this.focused = false } } private toggleFocus = () => { this.focused = !this.focused // wfem.notifyEvent('windowFormFocus', this.wid) this.dispatchEvent( new CustomEvent('update:focused', { detail: this.focused, bubbles: true, composed: 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.pointStartX = e.clientX this.pointStartY = e.clientY this.originalX = this.x this.originalY = this.y this.setPointerCapture?.(e.pointerId) this.dispatchEvent( new CustomEvent('windowForm:dragStart', { detail: { x: this.x, y: this.y }, bubbles: true, composed: true, }), ) } /** * 鼠标指针移动 * @param e */ private onPointerMove = (e: PointerEvent) => { if (this.dragging) { const dx = e.clientX - this.pointStartX const dy = e.clientY - this.pointStartY const x = this.originalX + dx const y = this.originalY + dy const pos = this.applyBoundary(x, y, e.clientX, e.clientY) this.applyPosition(pos.x, pos.y) this.dispatchEvent( new CustomEvent('windowForm:dragMove', { detail: { x, y }, bubbles: true, composed: true, }), ) } else if (this.resizeDir) { this.performResize(e) } } /** * 鼠标指针抬起 * @param e */ private onPointerUp = (e: PointerEvent) => { if (this.dragging) { this.dragUp(e) } if (this.resizeDir) { this.resizeUp(e) } this.dragging = false this.resizing = false this.resizeDir = null document.body.style.cursor = '' 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 * @returns {x: number, y: number} 新的位置坐标 */ private calculateSnapping(x: number, y: number): { x: number, y: number} { let snappedX = x let 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 } } /** * 拖拽结束 * @param e * @private */ private dragUp(e: PointerEvent) { const snapped = this.calculateSnapping(this.x, this.y) if (this.snapAnimation) { this.animateTo(this.x, this.y, snapped.x, snapped.y, this.snapAnimationDuration, (x, y) => { this.applyPosition(x, y) }, (x, y) => { this.applyPosition(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) this.dispatchEvent( new CustomEvent('windowForm:dragEnd', { detail: { x: snapped.x, y: snapped.y }, bubbles: true, composed: true, }), ) } this.updateTargetBounds(this.x, this.y, this.width, this.height) } /** * 根据鼠标指针的位置是否在容器边界内来限制窗口坐标 * @param x 当前元素的左上角坐标 x * @param y 当前元素的左上角坐标 y * @param pointerX 当前鼠标在容器中的 X 坐标 * @param pointerY 当前鼠标在容器中的 Y 坐标 * @returns 限制后的坐标点 { x, y } */ private applyBoundary(x: number, y: number, pointerX: number, pointerY: number): { x: number; y: number } { const containerRect = (this.dragContainer || document.body).getBoundingClientRect() // 限制指针在容器内 const limitedPointerX = Math.min(Math.max(pointerX, containerRect.left), containerRect.right) const limitedPointerY = Math.min(Math.max(pointerY, containerRect.top), containerRect.bottom) // 计算指针被限制后的偏移量 const dx = limitedPointerX - pointerX const dy = limitedPointerY - pointerY // 根据指针偏移调整窗口位置 x += dx y += dy return { x, y } } /** * 设置拖拽的窗口位置 * @param x 当前元素的左上角坐标点 x * @param y 当前元素的左上角坐标点 y * @private */ private applyPosition(x: number, y: number) { this.x = x this.y = y this.style.transform = `translate(${x}px, ${y}px)` } /** * 动画移动窗口 * @param startX 窗口起始点 x * @param startY 窗口起始点 y * @param targetX 目标点 x * @param targetY 目标点 y * @param duration 动画时长 * @param onMove 移动回调 * @param onComplete 完成回调 * @private */ private animateTo( startX: number, startY: number, targetX: number, targetY: number, duration: number, onMove?: (x: number, y: number) => void, onComplete?: (x: number, y: number) => void ) { if (this.animationFrame) cancelAnimationFrame(this.animationFrame) 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 onMove?.(x, y) this.dispatchEvent( new CustomEvent('windowForm:dragMove', { detail: { x, y }, bubbles: true, composed: true, }), ) if (progress < 1) { this.animationFrame = requestAnimationFrame(step) } else { onComplete?.(targetX, targetY) } } 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.resizing = true this.resizeDir = dir this.pointStartX = e.clientX this.pointStartY = e.clientY this.originalX = this.x this.originalY = this.y this.originalWidth = this.width this.originalHeight = this.height const target = e.target as HTMLElement document.body.style.cursor = target.style.cursor || window.getComputedStyle(target).cursor 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, }), ) } /** * 缩放 * @param e * @private */ private performResize(e: PointerEvent) { if (!this.resizeDir || !this.resizing) return let newWidth = this.originalWidth let newHeight = this.originalHeight let newX = this.originalX let newY = this.originalY const dx = e.clientX - this.pointStartX const dy = e.clientY - this.pointStartY // ====== 根据方向计算临时尺寸与位置 ====== 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 { x, y, width, height } = this.applyResizeBounds(newX, newY, newWidth, newHeight, this.resizeDir) this.x = x this.y = y this.width = width this.height = height this.style.width = `${this.width}px` this.style.height = `${this.height}px` this.style.transform = `translate(${this.x}px, ${this.y}px)` this.dispatchEvent( new CustomEvent('windowForm:resizeMove', { detail: { dir: this.resizeDir, width: width, height: height, left: x, top: y }, bubbles: true, composed: true, }), ) } /** * 计算尺寸调整约束逻辑,返回约束后的尺寸 * @param x 坐标 x * @param y 坐标 y * @param width 宽度 * @param height 高度 * @private * @returns { x: number, y: number, width: number, height: number } 约束后的尺寸 */ private applyResizeBounds( x: number, y: number, width: number, height: number, resizeDir: TResizeDirection ): { x: number y: number width: number height: number } { const { minWidth = 100, minHeight = 100, maxWidth = Infinity, maxHeight = Infinity } = this //#region 限制最大/最小尺寸 // 限制宽度 if (width < minWidth) { // 左缩时要修正X坐标,否则会跳动 if (resizeDir.includes('l')) x -= minWidth - width width = minWidth } else if (width > maxWidth) { if (resizeDir.includes('l')) x += width - maxWidth width = maxWidth } // 限制高度 if (height < minHeight) { if (resizeDir.includes('t')) y -= minHeight - height height = minHeight } else if (height > maxHeight) { if (resizeDir.includes('t')) y += height - maxHeight height = maxHeight } //#endregion //#region 限制在容器边界内 const containerRect = (this.dragContainer || document.body).getBoundingClientRect() const maxLeft = containerRect.width - width const maxTop = containerRect.height - height // 左越界(从左侧缩放时) if (x < 0) { if (resizeDir.includes('l')) { // 如果是往左拉出容器,锁定边界 width += x // 因为x是负数,相当于减小宽度 } x = 0 } // 顶部越界(从上侧缩放时) if (y < 0) { if (resizeDir.includes('t')) { height += y // y是负数,相当于减小高度 } y = 0 } // 右越界(从右侧缩放时) if (x + width > containerRect.width) { if (resizeDir.includes('r')) { width = containerRect.width - x } else { x = Math.min(x, maxLeft) } } // 底部越界(从下侧缩放时) if (y + height > containerRect.height) { if (resizeDir.includes('b')) { height = containerRect.height - y } else { y = Math.min(y, maxTop) } } //#endregion // 二次防护:确保不小于最小值 width = Math.max(width, minWidth) height = Math.max(height, minHeight) return { x, y, width, height } } /** * 缩放结束 * @param e * @private */ 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.x, rect.y, rect.width, rect.height, 400, (x, y, w, h) => { this.applyWindowStyle(x, y, w, h) }, (x, y, w, h) => { this.applyWindowStyle(x, y, w, h) this.style.display = 'none' this.dispatchEvent( new CustomEvent('windowForm:stateChange:minimize', { detail: { state: this.windowFormState }, bubbles: true, composed: true, }) ) } ) } /** 最大化 */ 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, (x, y, w, h) => { this.applyWindowStyle(x, y, w, h) }, (x, y, w, h) => { this.applyWindowStyle(x, y, w, h) this.dispatchEvent( new CustomEvent('windowForm:stateChange:maximize', { detail: { state: this.windowFormState }, bubbles: true, composed: true, }), ) }, ) } /** 恢复到默认窗体状态 */ private restore(onComplete?: () => void) { 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.x, b.y, b.width, b.height, 300, (x, y, w, h) => { this.applyWindowStyle(x, y, w, h) }, (x, y, w, h) => { this.applyWindowStyle(x, y, w, h) onComplete?.() this.dispatchEvent( new CustomEvent('windowForm:stateChange:restore', { detail: { state: this.windowFormState }, bubbles: true, composed: true, }), ) }, ) } /** * 应用窗体样式 * @param x * @param y * @param w * @param h * @private */ private applyWindowStyle(x: number, y: number, w: number, h: number) { this.width = w this.height = h this.x = x this.y = y this.style.width = `${w}px` this.style.height = `${h}px` this.style.transform = `translate(${x}px, ${y}px)` } private windowFormClose() { this.dispatchEvent( new CustomEvent('windowForm:close', { bubbles: true, composed: true, }), ) } /** * 窗体最大化、最小化和恢复默认 动画 * @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, onUpdating?: (x: number, y: number, w: number, h: number) => void, onComplete?: (x: number, y: number, w: number, h: number) => 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 onUpdating?.(x, y, w, h) if (progress < 1) { requestAnimationFrame(step) } else { this.style.width = `${targetW}px` this.style.height = `${targetH}px` onComplete?.(x, y, w, h) this.dispatchEvent( new CustomEvent('windowForm:stateChange', { detail: { state: this.windowFormState }, bubbles: true, composed: true, }) ) } } requestAnimationFrame(step) } private updateTargetBounds(x: number, y: number, width?: number, height?: number) { this.targetBounds = { x, y, width: width ?? this.offsetWidth, height: height ?? this.offsetHeight, } } // ====== 渲染 ====== override render() { return html`
${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; } interface WindowFormElementEventMap extends WindowFormEventMap {} }