Files
vue-desktop/src/ui/webComponents/WindowFormElement.ts
2025-10-24 11:07:27 +08:00

905 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** 顶点坐标(相对 offsetParent */
top: number;
/** 左点坐标(相对 offsetParent */
left: number;
}
/** 窗口自定义事件 */
export interface WindowFormEventMap extends HTMLElementEventMap {
'windowForm:dragStart': CustomEvent<TDragStartCallback>;
'windowForm:dragMove': CustomEvent<TDragMoveCallback>;
'windowForm:dragEnd': CustomEvent<TDragEndCallback>;
'windowForm:resizeStart': CustomEvent<IResizeCallbackData>;
'windowForm:resizeMove': CustomEvent<IResizeCallbackData>;
'windowForm:resizeEnd': CustomEvent<IResizeCallbackData>;
'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: Boolean }) allowOverflow = true // 允许窗口超出容器
@property({ type: Number }) snapDistance = 20 // 吸附距离
@property({ type: Boolean }) snapAnimation = true // 吸附动画
@property({ type: Number }) snapAnimationDuration = 300 // 吸附动画时长 ms
@property({ type: Number }) maxWidth?: number = Infinity
@property({ type: Number }) minWidth?: number = 0
@property({ type: Number }) maxHeight?: number = Infinity
@property({ type: Number }) minHeight?: number = 0
@property({ type: String }) taskbarElementId?: string
@property({ type: Object }) wfData: any;
private _listeners: Array<{ type: string; original: Function; wrapped: EventListener }> = []
// ==== 拖拽/缩放状态(内部变量,不触发渲染) ====
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 resizing = false
// 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<K extends keyof WindowFormEventMap>(
type: K,
handler: (this: WindowFormElement, ev: WindowFormEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): void
public addManagedEventListener<K extends keyof WindowFormEventMap>(
type: K,
handler: (ev: WindowFormEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): void
/**
* 添加受管理的事件监听
* @param type 事件类型
* @param handler 事件处理函数
*/
public addManagedEventListener<K extends keyof WindowFormEventMap>(
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<K extends keyof WindowFormEventMap>(
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() {
console.log(this.wfData)
// wfem.addEventListener('windowFormFocus', this.windowFormFocusFun)
window.addEventListener('pointerup', this.onPointerUp)
window.addEventListener('pointermove', this.onPointerMove)
this.addEventListener('pointerdown', this.toggleFocus)
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)
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.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.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
*/
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.resizing = true
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
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,
}),
)
}
private performResize(e: PointerEvent) {
if (!this.resizeDir || !this.resizing) 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'
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,
() => {
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.left,
b.top,
b.width,
b.height,
300,
() => {
onComplete?.()
this.dispatchEvent(
new CustomEvent('windowForm:stateChange:restore', {
detail: { state: this.windowFormState },
bubbles: true,
composed: true,
}),
)
},
)
}
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,
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() {
return html`
<div class="window">
<div class="titlebar" @pointerdown=${this.onTitlePointerDown}>
<div class="title" title=${this.title}>${this.title}</div>
<div class="controls">
${this.minimizable
? html`<button
class="ctrl"
@click=${(e: Event) => {
e.stopPropagation()
this.minimize()
}}
>
</button>`
: null}
${this.maximizable
? html`<button
class="ctrl"
@click=${(e: Event) => {
e.stopPropagation()
this.maximize()
}}
>
${this.windowFormState === 'maximized' ? '▣' : '▢'}
</button>`
: null}
${this.closable
? html`<button
class="ctrl"
@click=${(e: Event) => {
e.stopPropagation()
this.windowFormClose()
}}
>
</button>`
: null}
</div>
</div>
<div class="content"><slot></slot></div>
${this.resizable
? html`
<div
class="resizer t"
@pointerdown=${(e: PointerEvent) => this.startResize('t', e)}
></div>
<div
class="resizer b"
@pointerdown=${(e: PointerEvent) => this.startResize('b', e)}
></div>
<div
class="resizer r"
@pointerdown=${(e: PointerEvent) => this.startResize('r', e)}
></div>
<div
class="resizer l"
@pointerdown=${(e: PointerEvent) => this.startResize('l', e)}
></div>
<div
class="resizer tr"
@pointerdown=${(e: PointerEvent) => this.startResize('tr', e)}
></div>
<div
class="resizer tl"
@pointerdown=${(e: PointerEvent) => this.startResize('tl', e)}
></div>
<div
class="resizer br"
@pointerdown=${(e: PointerEvent) => this.startResize('br', e)}
></div>
<div
class="resizer bl"
@pointerdown=${(e: PointerEvent) => this.startResize('bl', e)}
></div>
`
: null}
</div>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'window-form-element': WindowFormElement;
}
interface WindowFormElementEventMap extends WindowFormEventMap {}
}