Files
vue-desktop/src/core/window/ui/WindowFormElement.ts
2025-09-15 15:33:58 +08:00

593 lines
19 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 './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`
<div class="minimized" @click=${this.restore}>
<div style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
${this.title}
</div>
</div>
`;
}
return html`
<div class="window" @pointerdown=${() => this.focused = true}>
<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.maximized ? '▣' : '▢'}</button>` : null}
${this.closable ? html`<button class="ctrl" @click=${(e:Event)=>{ e.stopPropagation(); }}>✕</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;
}
}