保存一下
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"lit": "^3.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^3.0.3",
|
||||
"uuid": "^11.1.0",
|
||||
|
||||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^13.6.0
|
||||
version: 13.6.0(vue@3.5.18(typescript@5.8.3))
|
||||
lit:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -413,6 +416,12 @@ packages:
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.4.0':
|
||||
resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==}
|
||||
|
||||
'@lit/reactive-element@2.1.1':
|
||||
resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -649,6 +658,9 @@ packages:
|
||||
'@types/node@22.17.1':
|
||||
resolution: {integrity: sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -1170,6 +1182,15 @@ packages:
|
||||
kolorist@1.8.0:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
lit-element@4.2.1:
|
||||
resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==}
|
||||
|
||||
lit-html@3.3.1:
|
||||
resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==}
|
||||
|
||||
lit@3.3.1:
|
||||
resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==}
|
||||
|
||||
local-pkg@1.1.1:
|
||||
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1920,6 +1941,12 @@ snapshots:
|
||||
|
||||
'@juggle/resize-observer@3.4.0': {}
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.4.0': {}
|
||||
|
||||
'@lit/reactive-element@2.1.1':
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.4.0
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
@@ -2071,6 +2098,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@unocss/astro@66.4.2(vite@7.1.1(@types/node@22.17.1)(jiti@2.5.1)(sass@1.90.0))':
|
||||
@@ -2679,6 +2708,22 @@ snapshots:
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
lit-element@4.2.1:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.4.0
|
||||
'@lit/reactive-element': 2.1.1
|
||||
lit-html: 3.3.1
|
||||
|
||||
lit-html@3.3.1:
|
||||
dependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
lit@3.3.1:
|
||||
dependencies:
|
||||
'@lit/reactive-element': 2.1.1
|
||||
lit-element: 4.2.1
|
||||
lit-html: 3.3.1
|
||||
|
||||
local-pkg@1.1.1:
|
||||
dependencies:
|
||||
mlly: 1.7.4
|
||||
|
||||
@@ -1,559 +0,0 @@
|
||||
/** 拖拽移动开始的回调 */
|
||||
type TDragStartCallback = (x: number, y: number) => void;
|
||||
/** 拖拽移动中的回调 */
|
||||
type TDragMoveCallback = (x: number, y: number) => void;
|
||||
/** 拖拽移动结束的回调 */
|
||||
type TDragEndCallback = (x: number, y: number) => void;
|
||||
|
||||
/** 拖拽调整尺寸的方向 */
|
||||
type TResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
/** 拖拽调整尺寸回调数据 */
|
||||
interface IResizeCallbackData {
|
||||
/** 宽度 */
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
/** 顶点坐标(相对 offsetParent) */
|
||||
top: number;
|
||||
/** 左点坐标(相对 offsetParent) */
|
||||
left: number;
|
||||
/** 拖拽调整尺寸的方向 */
|
||||
direction: TResizeDirection;
|
||||
}
|
||||
|
||||
/** 拖拽/调整尺寸 参数 */
|
||||
interface IDraggableResizableOptions {
|
||||
/** 拖拽/调整尺寸目标元素 */
|
||||
target: HTMLElement;
|
||||
/** 拖拽句柄 */
|
||||
handle?: HTMLElement;
|
||||
/** 拖拽模式 */
|
||||
mode?: 'transform' | 'position';
|
||||
/** 拖拽边界或容器元素 */
|
||||
boundary?: IBoundaryRect | HTMLElement;
|
||||
/** 移动步进(网格吸附) */
|
||||
snapGrid?: number;
|
||||
/** 关键点吸附阈值 */
|
||||
snapThreshold?: number;
|
||||
/** 是否开启吸附动画 */
|
||||
snapAnimation?: boolean;
|
||||
/** 拖拽结束吸附动画时长 */
|
||||
snapAnimationDuration?: number;
|
||||
/** 是否允许超出边界 */
|
||||
allowOverflow?: boolean;
|
||||
|
||||
/** 拖拽开始回调 */
|
||||
onDragStart?: TDragStartCallback;
|
||||
/** 拖拽移动中的回调 */
|
||||
onDragMove?: TDragMoveCallback;
|
||||
/** 拖拽结束回调 */
|
||||
onDragEnd?: TDragEndCallback;
|
||||
|
||||
/** 调整尺寸的最小宽度 */
|
||||
minWidth?: number;
|
||||
/** 调整尺寸的最小高度 */
|
||||
minHeight?: number;
|
||||
/** 调整尺寸的最大宽度 */
|
||||
maxWidth?: number;
|
||||
/** 调整尺寸的最大高度 */
|
||||
maxHeight?: number;
|
||||
|
||||
/** 拖拽调整尺寸中的回调 */
|
||||
onResizeMove?: (data: IResizeCallbackData) => void;
|
||||
/** 拖拽调整尺寸结束回调 */
|
||||
onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||
}
|
||||
|
||||
/** 拖拽的范围边界 */
|
||||
interface IBoundaryRect {
|
||||
/** 最小 X 坐标 */
|
||||
minX?: number;
|
||||
/** 最大 X 坐标 */
|
||||
maxX?: number;
|
||||
/** 最小 Y 坐标 */
|
||||
minY?: number;
|
||||
/** 最大 Y 坐标 */
|
||||
maxY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽 + 调整尺寸通用类
|
||||
*/
|
||||
export class DraggableResizable {
|
||||
private handle?: HTMLElement;
|
||||
private target: HTMLElement;
|
||||
private boundary?: HTMLElement | IBoundaryRect;
|
||||
private mode: 'transform' | 'position';
|
||||
private snapGrid: number;
|
||||
private snapThreshold: number;
|
||||
private snapAnimation: boolean;
|
||||
private snapAnimationDuration: number;
|
||||
private allowOverflow: boolean;
|
||||
|
||||
private onDragStart?: TDragStartCallback;
|
||||
private onDragMove?: TDragMoveCallback;
|
||||
private onDragEnd?: TDragEndCallback;
|
||||
|
||||
private isDragging = false;
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private offsetX = 0;
|
||||
private offsetY = 0;
|
||||
private currentX = 0;
|
||||
private currentY = 0;
|
||||
|
||||
private containerRect?: DOMRect;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private mutationObserver: MutationObserver;
|
||||
private animationFrame?: number;
|
||||
|
||||
private currentDirection: TResizeDirection | null = null;
|
||||
private startWidth = 0;
|
||||
private startHeight = 0;
|
||||
private startTop = 0;
|
||||
private startLeft = 0;
|
||||
private minWidth: number;
|
||||
private minHeight: number;
|
||||
private maxWidth: number;
|
||||
private maxHeight: number;
|
||||
private onResizeMove?: (data: IResizeCallbackData) => void;
|
||||
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||
|
||||
constructor(options: IDraggableResizableOptions) {
|
||||
// Drag
|
||||
this.handle = options.handle;
|
||||
this.target = options.target;
|
||||
this.boundary = options.boundary;
|
||||
this.mode = options.mode ?? 'transform';
|
||||
this.snapGrid = options.snapGrid ?? 1;
|
||||
this.snapThreshold = options.snapThreshold ?? 0;
|
||||
this.snapAnimation = options.snapAnimation ?? false;
|
||||
this.snapAnimationDuration = options.snapAnimationDuration ?? 200;
|
||||
this.allowOverflow = options.allowOverflow ?? true;
|
||||
|
||||
this.onDragStart = options.onDragStart;
|
||||
this.onDragMove = options.onDragMove;
|
||||
this.onDragEnd = options.onDragEnd;
|
||||
|
||||
// Resize
|
||||
this.minWidth = options.minWidth ?? 100;
|
||||
this.minHeight = options.minHeight ?? 50;
|
||||
this.maxWidth = options.maxWidth ?? window.innerWidth;
|
||||
this.maxHeight = options.maxHeight ?? window.innerHeight;
|
||||
this.onResizeMove = options.onResizeMove;
|
||||
this.onResizeEnd = options.onResizeEnd;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/** 初始化事件 */
|
||||
private init() {
|
||||
if (this.handle) {
|
||||
this.handle.addEventListener('mousedown', this.onMouseDownDrag);
|
||||
}
|
||||
|
||||
this.target.addEventListener('mousedown', this.onMouseDownResize);
|
||||
this.target.addEventListener('mouseleave', this.onMouseLeave);
|
||||
document.addEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
||||
|
||||
if (this.boundary instanceof HTMLElement) {
|
||||
this.observeResize(this.boundary);
|
||||
}
|
||||
|
||||
// 监听目标 DOM 是否被移除,自动销毁
|
||||
this.mutationObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node === this.target) {
|
||||
this.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (this.target.parentElement) {
|
||||
this.mutationObserver.observe(this.target.parentElement, { childList: true });
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseDownDrag = (e: MouseEvent) => {
|
||||
if (this.getResizeDirection(e)) return; // 避免和 resize 冲突
|
||||
e.preventDefault();
|
||||
this.startDrag(e);
|
||||
};
|
||||
|
||||
private startDrag(e: MouseEvent) {
|
||||
this.isDragging = true;
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
|
||||
if (this.mode === 'position') {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
const parentRect = this.target.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
this.offsetX = rect.left - parentRect.left;
|
||||
this.offsetY = rect.top - parentRect.top;
|
||||
} else {
|
||||
this.offsetX = this.currentX;
|
||||
this.offsetY = this.currentY;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', this.onMouseMoveDrag);
|
||||
document.addEventListener('mouseup', this.onMouseUpDrag);
|
||||
|
||||
this.onDragStart?.(this.offsetX, this.offsetY);
|
||||
}
|
||||
|
||||
private onMouseMoveDrag = (e: MouseEvent) => {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const dx = e.clientX - this.startX;
|
||||
const dy = e.clientY - this.startY;
|
||||
|
||||
let newX = this.offsetX + dx;
|
||||
let newY = this.offsetY + dy;
|
||||
|
||||
if (this.snapGrid > 1) {
|
||||
newX = Math.round(newX / this.snapGrid) * this.snapGrid;
|
||||
newY = Math.round(newY / this.snapGrid) * this.snapGrid;
|
||||
}
|
||||
|
||||
this.applyPosition(newX, newY, false);
|
||||
this.onDragMove?.(newX, newY);
|
||||
};
|
||||
|
||||
private onMouseUpDrag = () => {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
|
||||
const snapped = this.applySnapping(this.currentX, this.currentY);
|
||||
|
||||
if (this.snapAnimation) {
|
||||
this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => {
|
||||
this.onDragEnd?.(snapped.x, snapped.y);
|
||||
});
|
||||
} else {
|
||||
this.applyPosition(snapped.x, snapped.y, true);
|
||||
this.onDragEnd?.(snapped.x, snapped.y);
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', this.onMouseMoveDrag);
|
||||
document.removeEventListener('mouseup', this.onMouseUpDrag);
|
||||
};
|
||||
|
||||
private applyPosition(x: number, y: number, isFinal: boolean) {
|
||||
this.currentX = x;
|
||||
this.currentY = y;
|
||||
|
||||
if (this.mode === 'position') {
|
||||
this.target.style.left = `${x}px`;
|
||||
this.target.style.top = `${y}px`;
|
||||
} else {
|
||||
this.target.style.transform = `translate(${x}px, ${y}px)`;
|
||||
}
|
||||
|
||||
if (isFinal) this.applyBoundary();
|
||||
}
|
||||
|
||||
private onMouseDownResize = (e: MouseEvent) => {
|
||||
const dir = this.getResizeDirection(e);
|
||||
if (!dir) return;
|
||||
e.preventDefault();
|
||||
this.startResize(e, dir);
|
||||
};
|
||||
|
||||
private startResize(e: MouseEvent, dir: TResizeDirection) {
|
||||
this.currentDirection = dir;
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
const parentRect = this.target.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
this.startWidth = rect.width;
|
||||
this.startHeight = rect.height;
|
||||
this.startTop = rect.top - parentRect.top;
|
||||
this.startLeft = rect.left - parentRect.left;
|
||||
|
||||
document.addEventListener('mousemove', this.onResizeDrag);
|
||||
document.addEventListener('mouseup', this.onResizeEndHandler);
|
||||
}
|
||||
|
||||
private onResizeDrag = (e: MouseEvent) => {
|
||||
if (!this.currentDirection) return;
|
||||
|
||||
let deltaX = e.clientX - this.startX;
|
||||
let deltaY = e.clientY - this.startY;
|
||||
|
||||
let newWidth = this.startWidth;
|
||||
let newHeight = this.startHeight;
|
||||
let newTop = this.startTop;
|
||||
let newLeft = this.startLeft;
|
||||
|
||||
switch (this.currentDirection) {
|
||||
case 'right':
|
||||
newWidth = this.startWidth + deltaX;
|
||||
break;
|
||||
case 'bottom':
|
||||
newHeight = this.startHeight + deltaY;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
newWidth = this.startWidth + deltaX;
|
||||
newHeight = this.startHeight + deltaY;
|
||||
break;
|
||||
case 'left':
|
||||
newWidth = this.startWidth - deltaX;
|
||||
newLeft = this.startLeft + deltaX;
|
||||
break;
|
||||
case 'top':
|
||||
newHeight = this.startHeight - deltaY;
|
||||
newTop = this.startTop + deltaY;
|
||||
break;
|
||||
case 'top-left':
|
||||
newWidth = this.startWidth - deltaX;
|
||||
newLeft = this.startLeft + deltaX;
|
||||
newHeight = this.startHeight - deltaY;
|
||||
newTop = this.startTop + deltaY;
|
||||
break;
|
||||
case 'top-right':
|
||||
newWidth = this.startWidth + deltaX;
|
||||
newHeight = this.startHeight - deltaY;
|
||||
newTop = this.startTop + deltaY;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
newWidth = this.startWidth - deltaX;
|
||||
newLeft = this.startLeft + deltaX;
|
||||
newHeight = this.startHeight + deltaY;
|
||||
break;
|
||||
}
|
||||
|
||||
newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth));
|
||||
newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight));
|
||||
|
||||
this.target.style.width = `${newWidth}px`;
|
||||
this.target.style.height = `${newHeight}px`;
|
||||
this.target.style.top = `${newTop}px`;
|
||||
this.target.style.left = `${newLeft}px`;
|
||||
|
||||
this.onResizeMove?.({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
top: newTop,
|
||||
left: newLeft,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
};
|
||||
|
||||
private onResizeEndHandler = () => {
|
||||
if (this.currentDirection) {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
const parentRect = this.target.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
this.onResizeEnd?.({
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: rect.top - parentRect.top,
|
||||
left: rect.left - parentRect.left,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
}
|
||||
|
||||
this.currentDirection = null;
|
||||
this.updateCursor(null);
|
||||
document.removeEventListener('mousemove', this.onResizeDrag);
|
||||
document.removeEventListener('mouseup', this.onResizeEndHandler);
|
||||
};
|
||||
|
||||
private getResizeDirection(e: MouseEvent): TResizeDirection | null {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
const offset = 8;
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
const top = y >= rect.top && y <= rect.top + offset;
|
||||
const bottom = y >= rect.bottom - offset && y <= rect.bottom;
|
||||
const left = x >= rect.left && x <= rect.left + offset;
|
||||
const right = x >= rect.right - offset && x <= rect.right;
|
||||
|
||||
if (top && left) return 'top-left';
|
||||
if (top && right) return 'top-right';
|
||||
if (bottom && left) return 'bottom-left';
|
||||
if (bottom && right) return 'bottom-right';
|
||||
if (top) return 'top';
|
||||
if (bottom) return 'bottom';
|
||||
if (left) return 'left';
|
||||
if (right) return 'right';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private updateCursor(direction: TResizeDirection | null) {
|
||||
if (!direction) {
|
||||
this.target.style.cursor = 'default';
|
||||
return;
|
||||
}
|
||||
const cursorMap: Record<TResizeDirection, string> = {
|
||||
top: 'ns-resize',
|
||||
bottom: 'ns-resize',
|
||||
left: 'ew-resize',
|
||||
right: 'ew-resize',
|
||||
'top-left': 'nwse-resize',
|
||||
'top-right': 'nesw-resize',
|
||||
'bottom-left': 'nesw-resize',
|
||||
'bottom-right': 'nwse-resize',
|
||||
};
|
||||
this.target.style.cursor = cursorMap[direction];
|
||||
}
|
||||
|
||||
private onDocumentMouseMoveCursor = (e: MouseEvent) => {
|
||||
if (this.currentDirection || this.isDragging) return;
|
||||
const dir = this.getResizeDirection(e);
|
||||
this.updateCursor(dir);
|
||||
};
|
||||
|
||||
private onMouseLeave = () => {
|
||||
if (!this.currentDirection && !this.isDragging) this.updateCursor(null);
|
||||
};
|
||||
|
||||
private animateTo(targetX: number, targetY: number, duration: number, onComplete?: () => void) {
|
||||
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
||||
|
||||
const startX = this.currentX;
|
||||
const startY = this.currentY;
|
||||
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.onDragMove?.(x, y);
|
||||
|
||||
if (progress < 1) {
|
||||
this.animationFrame = requestAnimationFrame(step);
|
||||
} else {
|
||||
this.applyPosition(targetX, targetY, true);
|
||||
this.onDragMove?.(targetX, targetY);
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
this.animationFrame = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private applyBoundary() {
|
||||
if (!this.boundary || this.allowOverflow) return;
|
||||
|
||||
let { x, y } = { x: this.currentX, y: this.currentY };
|
||||
|
||||
if (this.boundary instanceof HTMLElement && this.containerRect) {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
const minX = 0;
|
||||
const minY = 0;
|
||||
const maxX = this.containerRect.width - rect.width;
|
||||
const maxY = this.containerRect.height - rect.height;
|
||||
|
||||
x = Math.min(Math.max(x, minX), maxX);
|
||||
y = Math.min(Math.max(y, minY), maxY);
|
||||
} else if (!(this.boundary instanceof HTMLElement)) {
|
||||
if (this.boundary.minX !== undefined) x = Math.max(x, this.boundary.minX);
|
||||
if (this.boundary.maxX !== undefined) x = Math.min(x, this.boundary.maxX);
|
||||
if (this.boundary.minY !== undefined) y = Math.max(y, this.boundary.minY);
|
||||
if (this.boundary.maxY !== undefined) y = Math.min(y, this.boundary.maxY);
|
||||
}
|
||||
|
||||
this.currentX = x;
|
||||
this.currentY = y;
|
||||
this.applyPosition(x, y, false);
|
||||
}
|
||||
|
||||
private applySnapping(x: number, y: number) {
|
||||
let { x: snappedX, y: snappedY } = { x, y };
|
||||
|
||||
// 1. 容器吸附
|
||||
const containerSnap = this.getSnapPoints();
|
||||
if (this.snapThreshold > 0) {
|
||||
for (const sx of containerSnap.x) {
|
||||
if (Math.abs(x - sx) <= this.snapThreshold) {
|
||||
snappedX = sx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const sy of containerSnap.y) {
|
||||
if (Math.abs(y - sy) <= this.snapThreshold) {
|
||||
snappedY = sy;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 窗口吸附 TODO
|
||||
|
||||
return { x: snappedX, y: snappedY };
|
||||
}
|
||||
|
||||
private getSnapPoints() {
|
||||
const snapPoints = { x: [] as number[], y: [] as number[] };
|
||||
|
||||
if (this.boundary instanceof HTMLElement && this.containerRect) {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
snapPoints.x = [0, this.containerRect.width - rect.width];
|
||||
snapPoints.y = [0, this.containerRect.height - rect.height];
|
||||
} else if (!(this.boundary instanceof HTMLElement) && this.boundary) {
|
||||
if (this.boundary.minX !== undefined) snapPoints.x.push(this.boundary.minX);
|
||||
if (this.boundary.maxX !== undefined) snapPoints.x.push(this.boundary.maxX);
|
||||
if (this.boundary.minY !== undefined) snapPoints.y.push(this.boundary.minY);
|
||||
if (this.boundary.maxY !== undefined) snapPoints.y.push(this.boundary.maxY);
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
}
|
||||
|
||||
private observeResize(container: HTMLElement) {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.containerRect = container.getBoundingClientRect();
|
||||
this.applyBoundary();
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
this.containerRect = container.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/** 销毁实例 */
|
||||
public destroy() {
|
||||
// 拖拽解绑:只在 handle 上解绑
|
||||
if (this.handle) {
|
||||
this.handle.removeEventListener('mousedown', this.onMouseDownDrag);
|
||||
}
|
||||
|
||||
// 调整尺寸解绑
|
||||
this.target.removeEventListener('mousedown', this.onMouseDownResize);
|
||||
this.target.removeEventListener('mouseleave', this.onMouseLeave);
|
||||
|
||||
// 全局事件解绑
|
||||
document.removeEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
||||
document.removeEventListener('mousemove', this.onMouseMoveDrag);
|
||||
document.removeEventListener('mouseup', this.onMouseUpDrag);
|
||||
document.removeEventListener('mousemove', this.onResizeDrag);
|
||||
document.removeEventListener('mouseup', this.onResizeEndHandler);
|
||||
|
||||
// 观察器清理
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
if (this.mutationObserver) this.mutationObserver.disconnect();
|
||||
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
||||
|
||||
// 所有属性置空,释放内存
|
||||
Object.keys(this).forEach(k => (this as any)[k] = null);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,9 @@ interface IDraggableResizableOptions {
|
||||
onResizeMove?: (data: IResizeCallbackData) => void;
|
||||
/** 拖拽调整尺寸结束回调 */
|
||||
onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||
|
||||
/** 窗口状态改变回调 */
|
||||
onWindowStateChange?: (state: TWindowState) => void;
|
||||
}
|
||||
|
||||
/** 拖拽的范围边界 */
|
||||
@@ -121,8 +124,11 @@ export class DraggableResizableWindow {
|
||||
private onResizeMove?: (data: IResizeCallbackData) => void;
|
||||
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||
|
||||
private onWindowStateChange?: (state: TWindowState) => void;
|
||||
|
||||
private isDragging = false;
|
||||
private currentDirection: TResizeDirection | null = null;
|
||||
private dragThreshold = 2; // 拖拽阈值 超过才开始真正的拖拽
|
||||
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
@@ -161,6 +167,10 @@ export class DraggableResizableWindow {
|
||||
private targetPreMaximizedBounds?: IElementRect;
|
||||
private taskbarElementId: string;
|
||||
|
||||
get windowState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
constructor(options: IDraggableResizableOptions) {
|
||||
this.handle = options.handle;
|
||||
this.target = options.target;
|
||||
@@ -181,6 +191,7 @@ export class DraggableResizableWindow {
|
||||
this.maxHeight = options.maxHeight ?? window.innerHeight;
|
||||
this.onResizeMove = options.onResizeMove;
|
||||
this.onResizeEnd = options.onResizeEnd;
|
||||
this.onWindowStateChange = options.onWindowStateChange;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.targetBounds = {
|
||||
@@ -225,16 +236,49 @@ export class DraggableResizableWindow {
|
||||
|
||||
private onMouseDownDrag = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.target !== this.handle) return;
|
||||
|
||||
if (this.getResizeDirection(e)) return;
|
||||
|
||||
if (this.state === 'maximized') {
|
||||
this.restore(() => this.startDrag(e));
|
||||
} else {
|
||||
this.startDrag(e);
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
|
||||
document.addEventListener('mousemove', this.checkDragStart);
|
||||
document.addEventListener('mouseup', this.cancelPendingDrag);
|
||||
}
|
||||
|
||||
private checkDragStart = (e: MouseEvent) => {
|
||||
const dx = e.clientX - this.startX;
|
||||
const dy = e.clientY - this.startY;
|
||||
|
||||
if (Math.abs(dx) > this.dragThreshold || Math.abs(dy) > this.dragThreshold) {
|
||||
// 超过阈值,真正开始拖拽
|
||||
document.removeEventListener('mousemove', this.checkDragStart);
|
||||
document.removeEventListener('mouseup', this.cancelPendingDrag);
|
||||
|
||||
if (this.state === 'maximized') {
|
||||
const preRect = this.targetPreMaximizedBounds!;
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
const relX = e.clientX / rect.width;
|
||||
const relY = e.clientY / rect.height;
|
||||
const newLeft = e.clientX - preRect.width * relX;
|
||||
const newTop = e.clientY - preRect.height * relY;
|
||||
this.targetPreMaximizedBounds = {
|
||||
width: preRect.width,
|
||||
height: preRect.height,
|
||||
top: newTop,
|
||||
left: newLeft,
|
||||
};
|
||||
|
||||
this.restore(() => this.startDrag(e));
|
||||
} else {
|
||||
this.startDrag(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private cancelPendingDrag = () => {
|
||||
document.removeEventListener('mousemove', this.checkDragStart);
|
||||
document.removeEventListener('mouseup', this.cancelPendingDrag);
|
||||
}
|
||||
|
||||
private startDrag = (e: MouseEvent) => {
|
||||
@@ -291,12 +335,12 @@ export class DraggableResizableWindow {
|
||||
if (this.snapAnimation) {
|
||||
this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => {
|
||||
this.onDragEnd?.(snapped.x, snapped.y);
|
||||
this.updateDefaultBounds(snapped.x, snapped.y);
|
||||
this.updateTargetBounds(snapped.x, snapped.y);
|
||||
});
|
||||
} else {
|
||||
this.applyPosition(snapped.x, snapped.y, true);
|
||||
this.onDragEnd?.(snapped.x, snapped.y);
|
||||
this.updateDefaultBounds(snapped.x, snapped.y);
|
||||
this.updateTargetBounds(snapped.x, snapped.y);
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', this.onMouseMoveDragRAF);
|
||||
@@ -507,7 +551,7 @@ export class DraggableResizableWindow {
|
||||
top: this.currentY,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
this.updateDefaultBounds(this.currentX, this.currentY, this.target.offsetWidth, this.target.offsetHeight);
|
||||
this.updateTargetBounds(this.currentX, this.currentY, this.target.offsetWidth, this.target.offsetHeight);
|
||||
this.currentDirection = null;
|
||||
this.updateCursor(null);
|
||||
document.removeEventListener('mousemove', this.onResizeDragRAF);
|
||||
@@ -575,11 +619,10 @@ export class DraggableResizableWindow {
|
||||
/** 最大化 */
|
||||
public maximize() {
|
||||
if (this.state === 'maximized') return;
|
||||
this.targetPreMinimizeBounds = { ...this.targetBounds }
|
||||
this.targetPreMaximizedBounds = { ...this.targetBounds }
|
||||
this.state = 'maximized';
|
||||
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
this.targetBounds = { width: rect.width, height: rect.height, left: rect.left, top: rect.top };
|
||||
|
||||
const startX = this.currentX;
|
||||
const startY = this.currentY;
|
||||
@@ -597,7 +640,6 @@ export class DraggableResizableWindow {
|
||||
/** 恢复到默认窗体状态 */
|
||||
public restore(onComplete?: () => void) {
|
||||
if (this.state === 'default') return;
|
||||
this.state = 'default';
|
||||
let b: IElementRect;
|
||||
if ((this.state as TWindowState) === 'minimized' && this.targetPreMinimizeBounds) {
|
||||
// 最小化恢复,恢复到最小化前的状态
|
||||
@@ -609,6 +651,8 @@ export class DraggableResizableWindow {
|
||||
b = this.targetBounds;
|
||||
}
|
||||
|
||||
this.state = 'default';
|
||||
|
||||
this.target.style.display = 'block';
|
||||
|
||||
const startX = this.currentX;
|
||||
@@ -667,12 +711,13 @@ export class DraggableResizableWindow {
|
||||
this.target.style.height = `${targetH}px`;
|
||||
this.applyPosition(targetX, targetY, true);
|
||||
onComplete?.();
|
||||
this.onWindowStateChange?.(this.state);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private updateDefaultBounds(left: number, top: number, width?: number, height?: number) {
|
||||
private updateTargetBounds(left: number, top: number, width?: number, height?: number) {
|
||||
this.targetBounds = {
|
||||
left, top,
|
||||
width: width ?? this.target.offsetWidth,
|
||||
@@ -699,6 +744,8 @@ export class DraggableResizableWindow {
|
||||
document.removeEventListener('mousemove', this.onResizeDragRAF);
|
||||
document.removeEventListener('mouseup', this.onResizeEndHandler);
|
||||
document.removeEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
||||
document.removeEventListener('mousemove', this.checkDragStart);
|
||||
document.removeEventListener('mouseup', this.cancelPendingDrag);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.mutationObserver.disconnect();
|
||||
cancelAnimationFrame(this.animationFrame ?? 0);
|
||||
|
||||
47
src/core/window/css/window-form.scss
Normal file
47
src/core/window/css/window-form.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
/* 窗体容器 */
|
||||
.window {
|
||||
width: 400px;
|
||||
border: 1px solid #666;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
background-color: #ffffff;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.title-bar {
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
color: black;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 窗体内容 */
|
||||
.window-content {
|
||||
padding: 15px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts
|
||||
import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||
import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts'
|
||||
import '../css/window-form.scss'
|
||||
|
||||
export default class WindowFormImpl implements IWindowForm {
|
||||
private readonly _id: string = uuidV4();
|
||||
@@ -40,49 +41,13 @@ export default class WindowFormImpl implements IWindowForm {
|
||||
}
|
||||
|
||||
private createWindowFrom() {
|
||||
this.dom = document.createElement('div');
|
||||
this.dom = this.windowFormEle();
|
||||
this.dom.style.position = 'absolute';
|
||||
this.dom.style.width = `${this.width}px`;
|
||||
this.dom.style.height = `${this.height}px`;
|
||||
this.dom.style.zIndex = '100';
|
||||
this.dom.style.backgroundColor = 'white';
|
||||
this.dom.classList.add('flex', 'flex-col', 'rd-[4px]')
|
||||
const header = document.createElement('div');
|
||||
header.style.width = '100%';
|
||||
header.style.height = '20px';
|
||||
header.style.backgroundColor = 'red';
|
||||
this.dom.appendChild(header)
|
||||
const bt1 = document.createElement('button');
|
||||
bt1.innerText = '最小化';
|
||||
bt1.addEventListener('click', () => {
|
||||
this.drw.minimize();
|
||||
setTimeout(() => {
|
||||
this.drw.restore();
|
||||
}, 2000)
|
||||
})
|
||||
header.appendChild(bt1)
|
||||
const bt2 = document.createElement('button');
|
||||
bt2.innerText = '最大化';
|
||||
bt2.addEventListener('click', () => {
|
||||
this.drw.maximize();
|
||||
})
|
||||
header.appendChild(bt2)
|
||||
const bt3 = document.createElement('button');
|
||||
bt3.innerText = '关闭';
|
||||
bt3.addEventListener('click', () => {
|
||||
this.closeWindowForm();
|
||||
})
|
||||
header.appendChild(bt3)
|
||||
const bt4 = document.createElement('button');
|
||||
bt4.innerText = '恢复';
|
||||
bt4.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation()
|
||||
this.drw.restore();
|
||||
})
|
||||
header.appendChild(bt4)
|
||||
|
||||
this.dom.style.zIndex = '10';
|
||||
|
||||
const header = this.dom.querySelector('.title-bar') as HTMLElement;
|
||||
this.drw = new DraggableResizableWindow({
|
||||
target: this.dom,
|
||||
handle: header,
|
||||
@@ -90,6 +55,13 @@ export default class WindowFormImpl implements IWindowForm {
|
||||
snapThreshold: 20,
|
||||
boundary: document.body,
|
||||
taskbarElementId: '#taskbar',
|
||||
onWindowStateChange: (state) => {
|
||||
if (state === 'maximized') {
|
||||
this.dom.style.borderRadius = '0px';
|
||||
} else {
|
||||
this.dom.style.borderRadius = '5px';
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
this.desktopRootDom.appendChild(this.dom);
|
||||
@@ -100,4 +72,43 @@ export default class WindowFormImpl implements IWindowForm {
|
||||
this.desktopRootDom.removeChild(this.dom);
|
||||
this.proc?.event.notifyEvent('onProcessWindowFormExit', this.id)
|
||||
}
|
||||
|
||||
private windowFormEle() {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = `
|
||||
<div class="window">
|
||||
<div class="title-bar">
|
||||
<div class="title">我的窗口</div>
|
||||
<div class="window-controls">
|
||||
<div class="minimize btn" title="最小化"">-</div>
|
||||
<div class="maximize btn" title="最大化">□</div>
|
||||
<div class="close btn" title="关闭">×</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<p>这是一个纯静态的 Windows 风格窗体示例。</p>
|
||||
<p>你可以在这里放置任何内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const fragment = template.content.cloneNode(true) as DocumentFragment;
|
||||
const windowElement = fragment.firstElementChild as HTMLElement
|
||||
|
||||
windowElement.querySelector('.btn.minimize')
|
||||
?.addEventListener('click', () => this.drw.minimize());
|
||||
|
||||
windowElement.querySelector('.btn.maximize')
|
||||
?.addEventListener('click', () => {
|
||||
if (this.drw.windowState === 'maximized') {
|
||||
this.drw.restore()
|
||||
} else {
|
||||
this.drw.maximize()
|
||||
}
|
||||
});
|
||||
|
||||
windowElement.querySelector('.btn.close')
|
||||
?.addEventListener('click', () => this.closeWindowForm());
|
||||
|
||||
return windowElement
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"experimentalDecorators": true,
|
||||
"target": "es2021",
|
||||
"lib": ["es2021", "dom"],
|
||||
"module": "ESNext",
|
||||
"strict": true, // 严格模式检查
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false, // 严格属性初始化检查
|
||||
"noUnusedLocals": false, // 检查未使用的局部变量
|
||||
"noUnusedParameters": false, // 检查未使用的参数
|
||||
"noImplicitReturns": true, // 检查函数所有路径是否都有返回值
|
||||
"noImplicitOverride": true, // 检查子类是否正确覆盖了父类方法
|
||||
"allowSyntheticDefaultImports": true // 允许使用默认导入
|
||||
"allowSyntheticDefaultImports": true, // 允许使用默认导入
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ import UnoCSS from 'unocss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.endsWith('-element') // 忽略自定义元素
|
||||
}
|
||||
}
|
||||
}),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
UnoCSS()
|
||||
|
||||
Reference in New Issue
Block a user