Compare commits
2 Commits
3401c8b737
...
08e08043d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 08e08043d7 | |||
| 9a30c2b3d7 |
@@ -379,7 +379,7 @@ export class DraggableResizableWindow {
|
|||||||
this.onDragMove?.(x, y);
|
this.onDragMove?.(x, y);
|
||||||
|
|
||||||
if (progress < 1) this.animationFrame = requestAnimationFrame(step);
|
if (progress < 1) this.animationFrame = requestAnimationFrame(step);
|
||||||
else { this.applyPosition(targetX, targetY, true); this.onDragMove?.(targetX, targetY); onComplete?.(); }
|
else { this.applyPosition(targetX, targetY, true); onComplete?.(); }
|
||||||
};
|
};
|
||||||
this.animationFrame = requestAnimationFrame(step);
|
this.animationFrame = requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
@@ -483,24 +483,26 @@ export class DraggableResizableWindow {
|
|||||||
case 'bottom-left': newWidth -= dx; newX += dx; newHeight += dy; break;
|
case 'bottom-left': newWidth -= dx; newX += dx; newHeight += dy; break;
|
||||||
}
|
}
|
||||||
|
|
||||||
newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth));
|
const d = this.applyResizeBounds(newX, newY, newWidth, newHeight);
|
||||||
newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight));
|
|
||||||
|
|
||||||
this.applyResizeBounds(newX, newY, newWidth, newHeight);
|
|
||||||
|
|
||||||
this.updateCursor(this.currentDirection);
|
this.updateCursor(this.currentDirection);
|
||||||
|
|
||||||
this.onResizeMove?.({
|
this.onResizeMove?.({
|
||||||
width: newWidth,
|
width: d.width,
|
||||||
height: newHeight,
|
height: d.height,
|
||||||
left: newX,
|
left: d.left,
|
||||||
top: newY,
|
top: d.top,
|
||||||
direction: this.currentDirection,
|
direction: this.currentDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用尺寸调整边界
|
// 应用尺寸调整边界
|
||||||
private applyResizeBounds(newX: number, newY: number, newWidth: number, newHeight: number) {
|
private applyResizeBounds(newX: number, newY: number, newWidth: number, newHeight: number): {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} {
|
||||||
// 最小/最大宽高限制
|
// 最小/最大宽高限制
|
||||||
newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth));
|
newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth));
|
||||||
newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight));
|
newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight));
|
||||||
@@ -512,7 +514,12 @@ export class DraggableResizableWindow {
|
|||||||
this.target.style.width = `${newWidth}px`;
|
this.target.style.width = `${newWidth}px`;
|
||||||
this.target.style.height = `${newHeight}px`;
|
this.target.style.height = `${newHeight}px`;
|
||||||
this.applyPosition(newX, newY, false);
|
this.applyPosition(newX, newY, false);
|
||||||
return;
|
return {
|
||||||
|
left: newX,
|
||||||
|
top: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newX = Math.min(Math.max(0, newX), this.containerRect.width - newWidth);
|
newX = Math.min(Math.max(0, newX), this.containerRect.width - newWidth);
|
||||||
@@ -523,6 +530,12 @@ export class DraggableResizableWindow {
|
|||||||
this.target.style.width = `${newWidth}px`;
|
this.target.style.width = `${newWidth}px`;
|
||||||
this.target.style.height = `${newHeight}px`;
|
this.target.style.height = `${newHeight}px`;
|
||||||
this.applyPosition(newX, newY, false);
|
this.applyPosition(newX, newY, false);
|
||||||
|
return {
|
||||||
|
left: newX,
|
||||||
|
top: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onResizeEndHandler = (e?: MouseEvent) => {
|
private onResizeEndHandler = (e?: MouseEvent) => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
|||||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts'
|
import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts'
|
||||||
import '../css/window-form.scss'
|
import '../css/window-form.scss'
|
||||||
|
import { serviceManager } from '@/core/service/kernel/ServiceManager.ts'
|
||||||
|
import '../ui/WindowFormElement.ts'
|
||||||
|
|
||||||
export default class WindowFormImpl implements IWindowForm {
|
export default class WindowFormImpl implements IWindowForm {
|
||||||
private readonly _id: string = uuidV4();
|
private readonly _id: string = uuidV4();
|
||||||
@@ -26,6 +28,9 @@ export default class WindowFormImpl implements IWindowForm {
|
|||||||
private get desktopRootDom() {
|
private get desktopRootDom() {
|
||||||
return XSystem.instance.desktopRootDom;
|
return XSystem.instance.desktopRootDom;
|
||||||
}
|
}
|
||||||
|
private get sm() {
|
||||||
|
return serviceManager.getService('WindowForm')
|
||||||
|
}
|
||||||
public get windowFormEle() {
|
public get windowFormEle() {
|
||||||
return this.dom;
|
return this.dom;
|
||||||
}
|
}
|
||||||
@@ -71,7 +76,14 @@ export default class WindowFormImpl implements IWindowForm {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.desktopRootDom.appendChild(this.dom);
|
// this.desktopRootDom.appendChild(this.dom);
|
||||||
|
const wf = document.createElement('window-form-element')
|
||||||
|
wf.dragContainer = document.body
|
||||||
|
wf.snapDistance = 20
|
||||||
|
wf.addEventListener('windowForm:dragStart', (e) => {
|
||||||
|
console.log('dragstart', e)
|
||||||
|
})
|
||||||
|
this.desktopRootDom.appendChild(wf)
|
||||||
}
|
}
|
||||||
|
|
||||||
public closeWindowForm() {
|
public closeWindowForm() {
|
||||||
|
|||||||
617
src/core/window/ui/WindowFormElement.ts
Normal file
617
src/core/window/ui/WindowFormElement.ts
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowFormEventMap {
|
||||||
|
'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 }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 resizing = false;
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
|
||||||
|
protected override createRenderRoot() {
|
||||||
|
const root = this.attachShadow({ mode: 'closed' });
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
sheet.replaceSync(wfStyle);
|
||||||
|
root.adoptedStyleSheets = [sheet];
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最大化 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/core/window/ui/wf.scss
Normal file
77
src/core/window/ui/wf.scss
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
:host {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
z-index: 10;
|
||||||
|
user-select: none;
|
||||||
|
--titlebar-height: 32px;
|
||||||
|
--shadow: 0 10px 30px rgba(0,0,0,0.25);
|
||||||
|
font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||||
|
}
|
||||||
|
.window {
|
||||||
|
position: absolute;
|
||||||
|
box-shadow: var(--shadow, 0 10px 30px rgba(0,0,0,0.25));
|
||||||
|
background: linear-gradient(#ffffff, #f6f6f6);
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.titlebar {
|
||||||
|
height: var(--titlebar-height, 32px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 8px;
|
||||||
|
background: linear-gradient(#f2f2f2, #e9e9e9);
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
.controls { display: flex; gap: 6px; }
|
||||||
|
button.ctrl {
|
||||||
|
width: 34px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
button.ctrl:hover { background: rgba(0,0,0,0.06); }
|
||||||
|
.content { flex: 1; overflow: auto; padding: 12px; background: transparent; }
|
||||||
|
|
||||||
|
.resizer { position: absolute; z-index: 20; }
|
||||||
|
.resizer.t { height: 6px; left: 0; right: 0; top: -3px; cursor: ns-resize; }
|
||||||
|
.resizer.b { height: 6px; left: 0; right: 0; bottom: -3px; cursor: ns-resize; }
|
||||||
|
.resizer.r { width: 6px; top: 0; bottom: 0; right: -3px; cursor: ew-resize; }
|
||||||
|
.resizer.l { width: 6px; top: 0; bottom: 0; left: -3px; cursor: ew-resize; }
|
||||||
|
.resizer.tr { width: 12px; height: 12px; right: -6px; top: -6px; cursor: nesw-resize; }
|
||||||
|
.resizer.tl { width: 12px; height: 12px; left: -6px; top: -6px; cursor: nwse-resize; }
|
||||||
|
.resizer.br { width: 12px; height: 12px; right: -6px; bottom: -6px; cursor: nwse-resize; }
|
||||||
|
.resizer.bl { width: 12px; height: 12px; left: -6px; bottom: -6px; cursor: nesw-resize; }
|
||||||
|
|
||||||
|
.minimized {
|
||||||
|
height: var(--titlebar-height, 32px);
|
||||||
|
width: 200px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(#f6f6f6,#efefef);
|
||||||
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
20
src/core/window/ui/window-form-helper.ts
Normal file
20
src/core/window/ui/window-form-helper.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { WindowFormEventMap } from '@/core/window/ui/WindowFormElement.ts'
|
||||||
|
|
||||||
|
export function addWindowFormEventListener<K extends keyof WindowFormEventMap>(
|
||||||
|
el: HTMLElement,
|
||||||
|
type: K,
|
||||||
|
listener: (ev: WindowFormEventMap[K]) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
) {
|
||||||
|
// 强制类型转换,保证 TS 不报错
|
||||||
|
el.addEventListener(type, listener as EventListener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWindowFormEventListener<K extends keyof WindowFormEventMap>(
|
||||||
|
el: HTMLElement,
|
||||||
|
type: K,
|
||||||
|
listener: (ev: WindowFormEventMap[K]) => any,
|
||||||
|
options?: boolean | EventListenerOptions
|
||||||
|
) {
|
||||||
|
el.removeEventListener(type, listener as EventListener, options);
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"lib": ["es2021", "dom"],
|
"lib": ["es2021", "dom"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"strict": true, // 严格模式检查
|
"strict": true, // 严格模式检查
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true, // 装饰器
|
||||||
|
"useDefineForClassFields": false,
|
||||||
"strictPropertyInitialization": false, // 严格属性初始化检查
|
"strictPropertyInitialization": false, // 严格属性初始化检查
|
||||||
"noUnusedLocals": false, // 检查未使用的局部变量
|
"noUnusedLocals": false, // 检查未使用的局部变量
|
||||||
"noUnusedParameters": false, // 检查未使用的参数
|
"noUnusedParameters": false, // 检查未使用的参数
|
||||||
|
|||||||
Reference in New Issue
Block a user