This commit is contained in:
2025-10-24 11:07:27 +08:00
parent ced6786f86
commit ce688a6834
3 changed files with 1025 additions and 41 deletions

View File

@@ -0,0 +1,904 @@
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 {}
}