1050 lines
29 KiB
TypeScript
1050 lines
29 KiB
TypeScript
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<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: 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<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() {
|
||
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`
|
||
<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 {}
|
||
}
|