Compare commits
22 Commits
f83dd91dbd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f80c1863b9 | |||
| ff4791922e | |||
| 16b4b27352 | |||
| e3ff2045ea | |||
| fd4f9aa66b | |||
| 62b4ae7379 | |||
| 68bdabf928 | |||
| e3cbba0607 | |||
| 3a6f5cdbba | |||
| 6eee4933e1 | |||
| 122fba228a | |||
| 08e08043d7 | |||
| 9a30c2b3d7 | |||
| 3401c8b737 | |||
| 67728c5c55 | |||
| 27b70ac35f | |||
| dc25d283d8 | |||
| 7cbb37d542 | |||
| d43664c945 | |||
| fcf3c2933c | |||
| 3d402de0af | |||
| 442a44dfc1 |
35
README.md
35
README.md
@@ -1,37 +1,2 @@
|
|||||||
# vue-desktop
|
# vue-desktop
|
||||||
|
|
||||||
浏览器:Chrome 84+、Edge 84+、Firefox 79+、Safari 14+
|
|
||||||
|
|
||||||
Node.js:v14+
|
|
||||||
|
|
||||||
不支持IE
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
|
||||||
|
|
||||||
## Type Support for `.vue` Imports in TS
|
|
||||||
|
|
||||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
|
||||||
|
|
||||||
## Customize configuration
|
|
||||||
|
|
||||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type-Check, Compile and Minify for Production
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.6.0",
|
||||||
|
"lit": "^3.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
|||||||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^13.6.0
|
specifier: ^13.6.0
|
||||||
version: 13.6.0(vue@3.5.18(typescript@5.8.3))
|
version: 13.6.0(vue@3.5.18(typescript@5.8.3))
|
||||||
|
lit:
|
||||||
|
specifier: ^3.3.1
|
||||||
|
version: 3.3.1
|
||||||
lodash:
|
lodash:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -413,6 +416,12 @@ packages:
|
|||||||
'@juggle/resize-observer@3.4.0':
|
'@juggle/resize-observer@3.4.0':
|
||||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
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':
|
'@parcel/watcher-android-arm64@2.5.1':
|
||||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -649,6 +658,9 @@ packages:
|
|||||||
'@types/node@22.17.1':
|
'@types/node@22.17.1':
|
||||||
resolution: {integrity: sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==}
|
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':
|
'@types/web-bluetooth@0.0.21':
|
||||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||||
|
|
||||||
@@ -1170,6 +1182,15 @@ packages:
|
|||||||
kolorist@1.8.0:
|
kolorist@1.8.0:
|
||||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
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:
|
local-pkg@1.1.1:
|
||||||
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
|
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1920,6 +1941,12 @@ snapshots:
|
|||||||
|
|
||||||
'@juggle/resize-observer@3.4.0': {}
|
'@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':
|
'@parcel/watcher-android-arm64@2.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2071,6 +2098,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.21': {}
|
'@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))':
|
'@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: {}
|
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:
|
local-pkg@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mlly: 1.7.4
|
mlly: 1.7.4
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import ProcessImpl from './process/impl/ProcessImpl.ts'
|
|
||||||
import { isUndefined } from 'lodash'
|
|
||||||
import { BasicSystemProcess } from '@/core/system/BasicSystemProcess.ts'
|
import { BasicSystemProcess } from '@/core/system/BasicSystemProcess.ts'
|
||||||
import { DesktopProcess } from '@/core/desktop/DesktopProcess.ts'
|
import { DesktopProcess } from '@/core/desktop/DesktopProcess.ts'
|
||||||
import type { IProcess } from '@/core/process/IProcess.ts'
|
|
||||||
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
|
||||||
import { ObservableWeakRefImpl } from '@/core/state/impl/ObservableWeakRefImpl.ts'
|
|
||||||
import type { IObservable } from '@/core/state/IObservable.ts'
|
|
||||||
import { NotificationService } from '@/core/service/services/NotificationService.ts'
|
import { NotificationService } from '@/core/service/services/NotificationService.ts'
|
||||||
import { SettingsService } from '@/core/service/services/SettingsService.ts'
|
import { SettingsService } from '@/core/service/services/SettingsService.ts'
|
||||||
import { WindowFormService } from '@/core/service/services/WindowFormService.ts'
|
import { WindowFormService } from '@/core/service/services/WindowFormService.ts'
|
||||||
import { UserService } from '@/core/service/services/UserService.ts'
|
import { UserService } from '@/core/service/services/UserService.ts'
|
||||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
|
|
||||||
interface IGlobalState {
|
|
||||||
isLogin: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class XSystem {
|
export default class XSystem {
|
||||||
private static _instance: XSystem = new XSystem()
|
private static _instance: XSystem = new XSystem()
|
||||||
|
|
||||||
private _globalState: IObservable<IGlobalState> = new ObservableWeakRefImpl<IGlobalState>({
|
|
||||||
isLogin: false
|
|
||||||
})
|
|
||||||
private _desktopRootDom: HTMLElement;
|
private _desktopRootDom: HTMLElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -35,44 +22,13 @@ export default class XSystem {
|
|||||||
public static get instance() {
|
public static get instance() {
|
||||||
return this._instance
|
return this._instance
|
||||||
}
|
}
|
||||||
public get globalState() {
|
|
||||||
return this._globalState
|
|
||||||
}
|
|
||||||
public get desktopRootDom() {
|
public get desktopRootDom() {
|
||||||
return this._desktopRootDom
|
return this._desktopRootDom
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialization(dom: HTMLDivElement) {
|
public async initialization(dom: HTMLDivElement) {
|
||||||
this._desktopRootDom = dom
|
this._desktopRootDom = dom
|
||||||
this.run('basic-system', BasicSystemProcess).then(() => {
|
await processManager.runProcess('basic-system', BasicSystemProcess)
|
||||||
this.run('desktop', DesktopProcess).then((proc) => {
|
await processManager.runProcess('desktop', DesktopProcess, dom)
|
||||||
proc.mount(dom)
|
|
||||||
// console.log(dom.querySelector('.desktop-root'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 运行进程
|
|
||||||
public async run<T extends IProcess = IProcess>(
|
|
||||||
proc: string | IProcessInfo,
|
|
||||||
constructor?: new (info: IProcessInfo) => T,
|
|
||||||
): Promise<T> {
|
|
||||||
let info = typeof proc === 'string' ? processManager.findProcessInfoByName(proc)! : proc
|
|
||||||
if (isUndefined(info)) {
|
|
||||||
throw new Error(`未找到进程信息:${proc}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 是单例应用
|
|
||||||
if (info.singleton) {
|
|
||||||
let process = processManager.findProcessByName(info.name)
|
|
||||||
if (process) {
|
|
||||||
return process as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建进程
|
|
||||||
let process = isUndefined(constructor) ? new ProcessImpl(info) : new constructor(info)
|
|
||||||
|
|
||||||
return process as T
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/core/common/types/IDestroyable.ts
Normal file
8
src/core/common/types/IDestroyable.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 可销毁接口
|
||||||
|
* 销毁实例,清理副作用,让内存可以被回收
|
||||||
|
*/
|
||||||
|
export interface IDestroyable {
|
||||||
|
/** 销毁实例,清理副作用,让内存可以被回收 */
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
@@ -5,92 +5,78 @@ import DesktopComponent from '@/core/desktop/ui/DesktopComponent.vue'
|
|||||||
import { naiveUi } from '@/core/common/naive-ui/components.ts'
|
import { naiveUi } from '@/core/common/naive-ui/components.ts'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
||||||
import { eventManager } from '@/core/events/EventManager.ts'
|
|
||||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
|
import './ui/DesktopElement.ts'
|
||||||
|
import { ObservableImpl } from '@/core/state/impl/ObservableImpl.ts'
|
||||||
|
|
||||||
|
interface IDesktopDataState {
|
||||||
|
/** 显示器宽度 */
|
||||||
|
monitorWidth: number;
|
||||||
|
/** 显示器高度 */
|
||||||
|
monitorHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class DesktopProcess extends ProcessImpl {
|
export class DesktopProcess extends ProcessImpl {
|
||||||
private _desktopRootDom: HTMLElement;
|
/** 桌面根dom,类似显示器 */
|
||||||
private _isMounted: boolean = false;
|
private readonly _monitorDom: HTMLElement
|
||||||
private _width: number = 0;
|
private _isMounted: boolean = false
|
||||||
private _height: number = 0;
|
private _data = new ObservableImpl<IDesktopDataState>({
|
||||||
private _pendingResize: boolean = false;
|
monitorWidth: 0,
|
||||||
|
monitorHeight: 0,
|
||||||
|
})
|
||||||
|
|
||||||
public get desktopRootDom() {
|
public get monitorDom() {
|
||||||
return this._desktopRootDom;
|
return this._monitorDom
|
||||||
}
|
}
|
||||||
public get isMounted() {
|
public get isMounted() {
|
||||||
return this._isMounted;
|
return this._isMounted
|
||||||
}
|
}
|
||||||
public get basicSystemProcess() {
|
public get basicSystemProcess() {
|
||||||
return processManager.findProcessByName<BasicSystemProcess>('basic-system')
|
return processManager.findProcessByName<BasicSystemProcess>('basic-system')
|
||||||
}
|
}
|
||||||
|
|
||||||
public get width() {
|
get data() {
|
||||||
return this._width;
|
return this._data
|
||||||
}
|
|
||||||
public set width(value: number) {
|
|
||||||
if (this._height === value) return;
|
|
||||||
if (!this._isMounted) return;
|
|
||||||
this._width = value;
|
|
||||||
this._desktopRootDom.style.width = value >= 0 ? `${value}px` : '100%';
|
|
||||||
|
|
||||||
this.scheduleResizeEvent()
|
|
||||||
}
|
|
||||||
public get height() {
|
|
||||||
return this._height;
|
|
||||||
}
|
|
||||||
public set height(value: number) {
|
|
||||||
if (this._height === value) return;
|
|
||||||
if (!this._isMounted) return;
|
|
||||||
this._height = value;
|
|
||||||
this._desktopRootDom.style.height = value >= 0 ? `${value}px` : '100%';
|
|
||||||
|
|
||||||
this.scheduleResizeEvent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleResizeEvent() {
|
constructor(info: IProcessInfo, dom: HTMLDivElement) {
|
||||||
if (this._pendingResize) return;
|
|
||||||
|
|
||||||
this._pendingResize = true;
|
|
||||||
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
if (this._pendingResize) {
|
|
||||||
this._pendingResize = false;
|
|
||||||
console.log('onDesktopRootDomResize')
|
|
||||||
eventManager.notifyEvent('onDesktopRootDomResize', this._width, this._height);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(info: IProcessInfo) {
|
|
||||||
super(info)
|
super(info)
|
||||||
console.log('DesktopProcess')
|
console.log('DesktopProcess')
|
||||||
}
|
|
||||||
|
|
||||||
public mount(dom: HTMLDivElement) {
|
|
||||||
console.log('DesktopProcess: start mount')
|
|
||||||
if (this._isMounted) return
|
|
||||||
this._width = window.innerWidth
|
|
||||||
this._height = window.innerHeight
|
|
||||||
window.addEventListener(
|
|
||||||
'resize',
|
|
||||||
debounce(() => {
|
|
||||||
this.width = window.innerWidth
|
|
||||||
this.height = window.innerHeight
|
|
||||||
}, 300)
|
|
||||||
)
|
|
||||||
|
|
||||||
dom.style.zIndex = '0';
|
|
||||||
dom.style.width = `${this._width}px`
|
|
||||||
dom.style.height = `${this._height}px`
|
|
||||||
dom.style.position = 'relative'
|
dom.style.position = 'relative'
|
||||||
dom.style.overflow = 'hidden'
|
dom.style.overflow = 'hidden'
|
||||||
this._desktopRootDom = dom
|
dom.style.width = `${window.innerWidth}px`
|
||||||
|
dom.style.height = `${window.innerHeight}px`
|
||||||
|
|
||||||
|
this._monitorDom = dom
|
||||||
|
this._data.state.monitorWidth = window.innerWidth
|
||||||
|
this._data.state.monitorHeight = window.innerHeight
|
||||||
|
window.addEventListener('resize', this.onResize)
|
||||||
|
|
||||||
|
this.createDesktopUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResize = debounce(() => {
|
||||||
|
this._monitorDom.style.width = `${window.innerWidth}px`
|
||||||
|
this._monitorDom.style.height = `${window.innerHeight}px`
|
||||||
|
this._data.state.monitorWidth = window.innerWidth
|
||||||
|
this._data.state.monitorHeight = window.innerHeight
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
private createDesktopUI() {
|
||||||
|
if (this._isMounted) return
|
||||||
const app = createApp(DesktopComponent, { process: this })
|
const app = createApp(DesktopComponent, { process: this })
|
||||||
app.use(naiveUi)
|
app.use(naiveUi)
|
||||||
app.mount(dom)
|
app.mount(this._monitorDom)
|
||||||
|
|
||||||
this._isMounted = true
|
this._isMounted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initDesktop(dom: HTMLDivElement) {
|
||||||
|
const d = document.createElement('desktop-element')
|
||||||
|
dom.appendChild(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
super.destroy()
|
||||||
|
window.removeEventListener('resize', this.onResize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,16 +5,20 @@
|
|||||||
>
|
>
|
||||||
<div class="desktop-root" @contextmenu="onContextMenu">
|
<div class="desktop-root" @contextmenu="onContextMenu">
|
||||||
<div class="desktop-bg">
|
<div class="desktop-bg">
|
||||||
<div class="desktop-icons-container"
|
<div class="desktop-icons-container" :style="gridStyle">
|
||||||
:style="gridStyle">
|
<AppIcon
|
||||||
<AppIcon v-for="(appIcon, i) in appIconsRef" :key="i"
|
v-for="(appIcon, i) in appIconsRef"
|
||||||
:iconInfo="appIcon" :gridTemplate="gridTemplate"
|
:key="i"
|
||||||
|
:iconInfo="appIcon"
|
||||||
|
:gridTemplate="gridTemplate"
|
||||||
@dblclick="runApp(appIcon)"
|
@dblclick="runApp(appIcon)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-bar">
|
<div class="task-bar">
|
||||||
<div id="taskbar" class="w-[80px] h-full flex justify-center items-center bg-blue">测试</div>
|
<div id="taskbar" class="w-[80px] h-full flex justify-center items-center bg-blue">
|
||||||
|
测试
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-config-provider>
|
</n-config-provider>
|
||||||
@@ -22,35 +26,46 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DesktopProcess } from '@/core/desktop/DesktopProcess.ts'
|
import type { DesktopProcess } from '@/core/desktop/DesktopProcess.ts'
|
||||||
import XSystem from '@/core/XSystem.ts'
|
|
||||||
import { notificationApi } from '@/core/common/naive-ui/discrete-api.ts'
|
import { notificationApi } from '@/core/common/naive-ui/discrete-api.ts'
|
||||||
import { configProviderProps } from '@/core/common/naive-ui/theme.ts'
|
import { configProviderProps } from '@/core/common/naive-ui/theme.ts'
|
||||||
import { useDesktopInit } from '@/core/desktop/ui/hooks/useDesktopInit.ts'
|
import { useDesktopInit } from '@/core/desktop/ui/hooks/useDesktopInit.ts'
|
||||||
import AppIcon from '@/core/desktop/ui/components/AppIcon.vue'
|
import AppIcon from '@/core/desktop/ui/components/AppIcon.vue'
|
||||||
import type { IDesktopAppIcon } from '@/core/desktop/types/IDesktopAppIcon.ts'
|
import type { IDesktopAppIcon } from '@/core/desktop/types/IDesktopAppIcon.ts'
|
||||||
import { eventManager } from '@/core/events/EventManager.ts'
|
import { eventManager } from '@/core/events/EventManager.ts'
|
||||||
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
|
|
||||||
const props = defineProps<{ process: DesktopProcess }>()
|
const props = defineProps<{ process: DesktopProcess }>()
|
||||||
|
|
||||||
const { appIconsRef, gridStyle, gridTemplate } = useDesktopInit('.desktop-icons-container')
|
props.process.data.subscribeKey(['monitorWidth', 'monitorHeight'], ({monitorWidth, monitorHeight}) => {
|
||||||
|
console.log('onDesktopRootDomResize', monitorWidth, monitorHeight)
|
||||||
eventManager.addEventListener('onDesktopRootDomResize',
|
|
||||||
(width, height) => {
|
|
||||||
console.log(width, height)
|
|
||||||
notificationApi.create({
|
notificationApi.create({
|
||||||
title: '桌面通知',
|
title: '桌面通知',
|
||||||
content: `桌面尺寸变化${width}x${height}}`,
|
content: `桌面尺寸变化${monitorWidth}x${monitorHeight}}`,
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
})
|
})
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
// props.process.data.subscribe((data) => {
|
||||||
|
// console.log('desktopData', data.monitorWidth)
|
||||||
|
// })
|
||||||
|
|
||||||
|
const { appIconsRef, gridStyle, gridTemplate } = useDesktopInit('.desktop-icons-container')
|
||||||
|
|
||||||
|
// eventManager.addEventListener('onDesktopRootDomResize', (width, height) => {
|
||||||
|
// console.log(width, height)
|
||||||
|
// notificationApi.create({
|
||||||
|
// title: '桌面通知',
|
||||||
|
// content: `桌面尺寸变化${width}x${height}}`,
|
||||||
|
// duration: 2000,
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
|
||||||
const onContextMenu = (e: MouseEvent) => {
|
const onContextMenu = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
const runApp = (appIcon: IDesktopAppIcon) => {
|
const runApp = (appIcon: IDesktopAppIcon) => {
|
||||||
XSystem.instance.run(appIcon.name)
|
processManager.runProcess(appIcon.name)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,7 +76,7 @@ $taskBarHeight: 40px;
|
|||||||
|
|
||||||
.desktop-bg {
|
.desktop-bg {
|
||||||
@apply w-full h-full flex-1 p-2 pos-relative;
|
@apply w-full h-full flex-1 p-2 pos-relative;
|
||||||
background-image: url("imgs/desktop-bg-2.jpeg");
|
background-image: url('imgs/desktop-bg-2.jpeg');
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
height: calc(100% - #{$taskBarHeight});
|
height: calc(100% - #{$taskBarHeight});
|
||||||
|
|||||||
35
src/core/desktop/ui/DesktopElement.ts
Normal file
35
src/core/desktop/ui/DesktopElement.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { css, html, LitElement, unsafeCSS } from 'lit'
|
||||||
|
import { customElement } from 'lit/decorators.js'
|
||||||
|
import desktopStyle from './css/desktop.scss?inline'
|
||||||
|
|
||||||
|
@customElement('desktop-element')
|
||||||
|
export class DesktopElement extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
${unsafeCSS(desktopStyle)}
|
||||||
|
`
|
||||||
|
|
||||||
|
private onContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
console.log('contextmenu')
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="desktop-root" @contextmenu=${this.onContextMenu}>
|
||||||
|
<div class="desktop-container">
|
||||||
|
<div class="desktop-icons-container"
|
||||||
|
:style="gridStyle">
|
||||||
|
<AppIcon v-for="(appIcon, i) in appIconsRef" :key="i"
|
||||||
|
:iconInfo="appIcon" :gridTemplate="gridTemplate"
|
||||||
|
@dblclick="runApp(appIcon)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-bar">
|
||||||
|
<div id="taskbar" class="w-[80px] h-full flex justify-center items-center bg-blue">测试</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/core/desktop/ui/components/DesktopAppIconElement.ts
Normal file
17
src/core/desktop/ui/components/DesktopAppIconElement.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { css, html, LitElement } from 'lit'
|
||||||
|
|
||||||
|
export class DesktopAppIconElement extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
@apply flex flex-col items-center justify-center bg-gray-200;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`<div class="desktop-app-icon">
|
||||||
|
<slot></slot>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/core/desktop/ui/css/desktop.scss
Normal file
31
src/core/desktop/ui/css/desktop.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box; /* 使用更直观的盒模型 */
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskBarHeight: 40px;
|
||||||
|
|
||||||
|
.desktop-root {
|
||||||
|
@apply w-full h-full flex flex-col;
|
||||||
|
|
||||||
|
.desktop-container {
|
||||||
|
@apply w-full h-full flex-1 p-2 pos-relative;
|
||||||
|
background-image: url("../imgs/desktop-bg-2.jpeg");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
height: calc(100% - #{$taskBarHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-icons-container {
|
||||||
|
@apply w-full h-full flex-1 grid grid-auto-flow-col pos-relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-bar {
|
||||||
|
@apply w-full bg-gray-200 flex justify-center items-center;
|
||||||
|
height: $taskBarHeight;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IDestroyable } from '@/core/common/types/IDestroyable.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件定义
|
* 事件定义
|
||||||
* @interface IEventMap 事件定义 键是事件名称,值是事件处理函数
|
* @interface IEventMap 事件定义 键是事件名称,值是事件处理函数
|
||||||
@@ -9,7 +11,7 @@ export interface IEventMap {
|
|||||||
/**
|
/**
|
||||||
* 事件管理器接口定义
|
* 事件管理器接口定义
|
||||||
*/
|
*/
|
||||||
export interface IEventBuilder<Events extends IEventMap> {
|
export interface IEventBuilder<Events extends IEventMap> extends IDestroyable {
|
||||||
/**
|
/**
|
||||||
* 添加事件监听
|
* 添加事件监听
|
||||||
* @param eventName 事件名称
|
* @param eventName 事件名称
|
||||||
|
|||||||
61
src/core/events/WindowFormEventManager.ts
Normal file
61
src/core/events/WindowFormEventManager.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { EventBuilderImpl } from '@/core/events/impl/EventBuilderImpl.ts'
|
||||||
|
import type { IEventMap } from '@/core/events/IEventBuilder.ts'
|
||||||
|
import type { TWindowFormState } from '@/core/window/types/WindowFormTypes.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口的事件
|
||||||
|
*/
|
||||||
|
export interface WindowFormEvent extends IEventMap {
|
||||||
|
/**
|
||||||
|
* 窗口最小化
|
||||||
|
* @param id 窗口id
|
||||||
|
*/
|
||||||
|
windowFormMinimize: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* 窗口最大化
|
||||||
|
* @param id 窗口id
|
||||||
|
*/
|
||||||
|
windowFormMaximize: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* 窗口还原
|
||||||
|
* @param id 窗口id
|
||||||
|
*/
|
||||||
|
windowFormRestore: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* 窗口关闭
|
||||||
|
* @param id 窗口id
|
||||||
|
*/
|
||||||
|
windowFormClose: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* 窗口聚焦
|
||||||
|
* @param id 窗口id
|
||||||
|
*/
|
||||||
|
windowFormFocus: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* 窗口数据更新
|
||||||
|
* @param data 窗口数据
|
||||||
|
*/
|
||||||
|
windowFormDataUpdate: (data: IWindowFormDataUpdateParams) => void;
|
||||||
|
/**
|
||||||
|
* 窗口创建完成
|
||||||
|
*/
|
||||||
|
windowFormCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IWindowFormDataUpdateParams {
|
||||||
|
/** 窗口id */
|
||||||
|
id: string;
|
||||||
|
/** 窗口状态 */
|
||||||
|
state: TWindowFormState,
|
||||||
|
/** 窗口宽度 */
|
||||||
|
width: number,
|
||||||
|
/** 窗口高度 */
|
||||||
|
height: number,
|
||||||
|
/** 窗口x坐标(左上角) */
|
||||||
|
x: number,
|
||||||
|
/** 窗口y坐标(左上角) */
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 窗口事件管理器 */
|
||||||
|
export const wfem = new EventBuilderImpl<WindowFormEvent>()
|
||||||
@@ -5,9 +5,7 @@ interface HandlerWrapper<T extends (...args: any[]) => any> {
|
|||||||
once: boolean
|
once: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EventBuilderImpl<Events extends IEventMap>
|
export class EventBuilderImpl<Events extends IEventMap> implements IEventBuilder<Events> {
|
||||||
implements IEventBuilder<Events>
|
|
||||||
{
|
|
||||||
private _eventHandlers = new Map<keyof Events, Set<HandlerWrapper<Events[keyof Events]>>>()
|
private _eventHandlers = new Map<keyof Events, Set<HandlerWrapper<Events[keyof Events]>>>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,9 +22,9 @@ export class EventBuilderImpl<Events extends IEventMap>
|
|||||||
eventName: E,
|
eventName: E,
|
||||||
handler: F,
|
handler: F,
|
||||||
options?: {
|
options?: {
|
||||||
immediate?: boolean;
|
immediate?: boolean
|
||||||
immediateArgs?: Parameters<F>;
|
immediateArgs?: Parameters<F>
|
||||||
once?: boolean;
|
once?: boolean
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (!handler) return
|
if (!handler) return
|
||||||
@@ -91,4 +89,8 @@ export class EventBuilderImpl<Events extends IEventMap>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._eventHandlers.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
||||||
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
||||||
|
import type { IEventBuilder } from '@/core/events/IEventBuilder.ts'
|
||||||
|
import type { IProcessEvent } from '@/core/process/types/ProcessEventTypes.ts'
|
||||||
|
import type { IDestroyable } from '@/core/common/types/IDestroyable.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进程接口
|
* 进程接口
|
||||||
*/
|
*/
|
||||||
export interface IProcess {
|
export interface IProcess extends IDestroyable {
|
||||||
/** 进程id */
|
/** 进程id */
|
||||||
get id(): string;
|
get id(): string;
|
||||||
/** 进程信息 */
|
/** 进程信息 */
|
||||||
get processInfo(): IProcessInfo;
|
get processInfo(): IProcessInfo;
|
||||||
/** 进程的窗体列表 */
|
/** 进程的窗体列表 */
|
||||||
get windowForms(): Map<string, IWindowForm>;
|
get windowForms(): Map<string, IWindowForm>;
|
||||||
|
get event(): IEventBuilder<IProcessEvent>;
|
||||||
/**
|
/**
|
||||||
* 打开窗体
|
* 打开窗体
|
||||||
* @param startName 窗体启动名
|
* @param startName 窗体启动名
|
||||||
*/
|
*/
|
||||||
openWindowForm(startName: string): void;
|
openWindowForm(startName: string): void;
|
||||||
|
/**
|
||||||
|
* 关闭窗体
|
||||||
|
* @param id 窗体id
|
||||||
|
*/
|
||||||
|
closeWindowForm(id: string): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import type { IProcess } from '@/core/process/IProcess.ts'
|
|||||||
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
||||||
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
||||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
|
import { EventBuilderImpl } from '@/core/events/impl/EventBuilderImpl.ts'
|
||||||
|
import type { IEventBuilder } from '@/core/events/IEventBuilder.ts'
|
||||||
|
import type { IProcessEvent } from '@/core/process/types/ProcessEventTypes.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进程
|
* 进程
|
||||||
@@ -13,6 +16,7 @@ export default class ProcessImpl implements IProcess {
|
|||||||
private readonly _processInfo: IProcessInfo;
|
private readonly _processInfo: IProcessInfo;
|
||||||
// 当前进程的窗体集合
|
// 当前进程的窗体集合
|
||||||
private _windowForms: Map<string, IWindowForm> = new Map();
|
private _windowForms: Map<string, IWindowForm> = new Map();
|
||||||
|
private _event: IEventBuilder<IProcessEvent> = new EventBuilderImpl<IProcessEvent>()
|
||||||
|
|
||||||
public get id() {
|
public get id() {
|
||||||
return this._id;
|
return this._id;
|
||||||
@@ -23,6 +27,9 @@ export default class ProcessImpl implements IProcess {
|
|||||||
public get windowForms() {
|
public get windowForms() {
|
||||||
return this._windowForms;
|
return this._windowForms;
|
||||||
}
|
}
|
||||||
|
public get event() {
|
||||||
|
return this._event;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(info: IProcessInfo) {
|
constructor(info: IProcessInfo) {
|
||||||
console.log(`AppProcess: ${info.name}`)
|
console.log(`AppProcess: ${info.name}`)
|
||||||
@@ -30,6 +37,8 @@ export default class ProcessImpl implements IProcess {
|
|||||||
|
|
||||||
const startName = info.startName;
|
const startName = info.startName;
|
||||||
|
|
||||||
|
this.initEvent();
|
||||||
|
|
||||||
processManager.registerProcess(this);
|
processManager.registerProcess(this);
|
||||||
// 通过设置 isJustProcess 为 true,则不会创建窗体
|
// 通过设置 isJustProcess 为 true,则不会创建窗体
|
||||||
if (!info.isJustProcess) {
|
if (!info.isJustProcess) {
|
||||||
@@ -37,10 +46,38 @@ export default class ProcessImpl implements IProcess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initEvent() {
|
||||||
|
this.event.addEventListener('processWindowFormExit', (id: string) => {
|
||||||
|
this.windowForms.delete(id)
|
||||||
|
if(this.windowForms.size === 0) {
|
||||||
|
processManager.removeProcess(this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
public openWindowForm(startName: string) {
|
public openWindowForm(startName: string) {
|
||||||
const info = this._processInfo.windowFormConfigs.find(item => item.name === startName);
|
const info = this._processInfo.windowFormConfigs.find(item => item.name === startName);
|
||||||
if (!info) throw new Error(`未找到窗体:${startName}`);
|
if (!info) throw new Error(`未找到窗体:${startName}`);
|
||||||
const window = new WindowFormImpl(this, info);
|
const wf = new WindowFormImpl(this, info);
|
||||||
this._windowForms.set(window.id, window);
|
this._windowForms.set(wf.id, wf);
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeWindowForm(id: string) {
|
||||||
|
try {
|
||||||
|
const wf = this._windowForms.get(id);
|
||||||
|
if (!wf) throw new Error(`未找到窗体:${id}`);
|
||||||
|
wf.destroy();
|
||||||
|
this.windowForms.delete(id)
|
||||||
|
if(this.windowForms.size === 0) {
|
||||||
|
this.destroy()
|
||||||
|
processManager.removeProcess(this)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('关闭窗体失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this._event.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type ProcessImpl from './ProcessImpl.ts'
|
import ProcessImpl from './ProcessImpl.ts'
|
||||||
import { ProcessInfoImpl } from '@/core/process/impl/ProcessInfoImpl.ts'
|
import { ProcessInfoImpl } from '@/core/process/impl/ProcessInfoImpl.ts'
|
||||||
import { BasicSystemProcessInfo } from '@/core/system/BasicSystemProcessInfo.ts'
|
import { BasicSystemProcessInfo } from '@/core/system/BasicSystemProcessInfo.ts'
|
||||||
import { DesktopProcessInfo } from '@/core/desktop/DesktopProcessInfo.ts'
|
import { DesktopProcessInfo } from '@/core/desktop/DesktopProcessInfo.ts'
|
||||||
@@ -6,6 +6,8 @@ import type { IAppProcessInfoParams } from '@/core/process/types/IAppProcessInfo
|
|||||||
import type { IProcessManager } from '@/core/process/IProcessManager.ts'
|
import type { IProcessManager } from '@/core/process/IProcessManager.ts'
|
||||||
import type { IProcess } from '@/core/process/IProcess.ts'
|
import type { IProcess } from '@/core/process/IProcess.ts'
|
||||||
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
||||||
|
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||||
|
import { isUndefined } from 'lodash'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进程管理
|
* 进程管理
|
||||||
@@ -35,6 +37,30 @@ export default class ProcessManagerImpl implements IProcessManager {
|
|||||||
this._processInfos.push(...internalProcessInfos)
|
this._processInfos.push(...internalProcessInfos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async runProcess<T extends IProcess = IProcess, A extends any[] = any[]>(
|
||||||
|
proc: string | IProcessInfo,
|
||||||
|
constructor?: new (info: IProcessInfo, ...args: A) => T,
|
||||||
|
...args: A
|
||||||
|
): Promise<T> {
|
||||||
|
let info = typeof proc === 'string' ? this.findProcessInfoByName(proc) : proc
|
||||||
|
if (isUndefined(info)) {
|
||||||
|
throw new Error(`未找到进程信息:${proc}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是单例应用
|
||||||
|
if (info.singleton) {
|
||||||
|
let process = this.findProcessByName(info.name)
|
||||||
|
if (process) {
|
||||||
|
return process as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建进程
|
||||||
|
let process = isUndefined(constructor) ? new ProcessImpl(info) : new constructor(info, ...args)
|
||||||
|
|
||||||
|
return process as T
|
||||||
|
}
|
||||||
|
|
||||||
// 添加进程
|
// 添加进程
|
||||||
public registerProcess(process: ProcessImpl) {
|
public registerProcess(process: ProcessImpl) {
|
||||||
this._processPool.set(process.id, process);
|
this._processPool.set(process.id, process);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IEventMap } from '@/core/events/IEventBuilder.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进程的事件
|
* 进程的事件
|
||||||
* <p>onProcessExit - 进程退出</p>
|
* <p>onProcessExit - 进程退出</p>
|
||||||
@@ -6,9 +8,17 @@
|
|||||||
* <p>onProcessWindowFormFocus - 进程的窗体获取焦点</p>
|
* <p>onProcessWindowFormFocus - 进程的窗体获取焦点</p>
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
type TAppProcessEvent =
|
type TProcessEvent =
|
||||||
'onProcessExit' |
|
'onProcessExit' |
|
||||||
'onProcessWindowFormOpen' |
|
'onProcessWindowFormOpen' |
|
||||||
'onProcessWindowFormExit' |
|
'onProcessWindowFormExit' |
|
||||||
'onProcessWindowFormFocus' |
|
'onProcessWindowFormFocus' |
|
||||||
'onProcessWindowFormBlur'
|
'onProcessWindowFormBlur'
|
||||||
|
|
||||||
|
export interface IProcessEvent extends IEventMap {
|
||||||
|
/**
|
||||||
|
* 进程的窗体退出
|
||||||
|
* @param id 窗体id
|
||||||
|
*/
|
||||||
|
processWindowFormExit: (id: string) => void
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { AService } from '@/core/service/kernel/AService.ts'
|
import { AService } from '@/core/service/kernel/AService.ts'
|
||||||
|
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
||||||
|
import WindowFormImpl from '@/core/window/impl/WindowFormImpl.ts'
|
||||||
|
import type { IProcess } from '@/core/process/IProcess.ts'
|
||||||
|
import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts'
|
||||||
|
|
||||||
interface IWindow {
|
interface IWindow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,67 +17,47 @@ interface IWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class WindowFormService extends AService {
|
export class WindowFormService extends AService {
|
||||||
private windows: Map<string, IWindow> = new Map();
|
private windows: Map<string, IWindowForm> = new Map();
|
||||||
private zCounter = 1;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("WindowForm");
|
super("WindowFormService");
|
||||||
console.log('WindowFormService - 服务注册')
|
console.log('WindowFormService - 服务注册')
|
||||||
}
|
}
|
||||||
|
|
||||||
createWindow(title: string, config?: Partial<IWindow>): IWindow {
|
public createWindow(proc: IProcess, info: IWindowFormConfig): IWindowForm {
|
||||||
const id = `win-${Date.now()}-${Math.random()}`;
|
const window = new WindowFormImpl(proc, info);
|
||||||
const win: IWindow = {
|
this.windows.set(window.id, window);
|
||||||
id,
|
return window;
|
||||||
title,
|
|
||||||
x: config?.x ?? 100,
|
|
||||||
y: config?.y ?? 100,
|
|
||||||
width: config?.width ?? 400,
|
|
||||||
height: config?.height ?? 300,
|
|
||||||
zIndex: this.zCounter++,
|
|
||||||
minimized: false,
|
|
||||||
maximized: false
|
|
||||||
};
|
|
||||||
this.windows.set(id, win);
|
|
||||||
this.sm.broadcast("WindowFrom:created", win);
|
|
||||||
return win;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeWindow(id: string) {
|
public closeWindow(id: string) {
|
||||||
if (this.windows.has(id)) {
|
if (this.windows.has(id)) {
|
||||||
this.windows.delete(id);
|
this.windows.delete(id);
|
||||||
this.sm.broadcast("WindowFrom:closed", id);
|
this.sm.broadcast("WindowFrom:closed", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
focusWindow(id: string) {
|
public focusWindow(id: string) {
|
||||||
const win = this.windows.get(id);
|
const win = this.windows.get(id);
|
||||||
if (win) {
|
if (win) {
|
||||||
win.zIndex = this.zCounter++;
|
|
||||||
this.sm.broadcast("WindowFrom:focused", win);
|
this.sm.broadcast("WindowFrom:focused", win);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
minimizeWindow(id: string) {
|
public minimizeWindow(id: string) {
|
||||||
const win = this.windows.get(id);
|
const win = this.windows.get(id);
|
||||||
if (win) {
|
if (win) {
|
||||||
win.minimized = true;
|
|
||||||
this.sm.broadcast("WindowFrom:minimized", win);
|
this.sm.broadcast("WindowFrom:minimized", win);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maximizeWindow(id: string) {
|
public maximizeWindow(id: string) {
|
||||||
const win = this.windows.get(id);
|
const win = this.windows.get(id);
|
||||||
if (win) {
|
if (win) {
|
||||||
win.maximized = !win.maximized;
|
|
||||||
this.sm.broadcast("WindowFrom:maximized", win);
|
this.sm.broadcast("WindowFrom:maximized", win);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getWindows(): IWindow[] {
|
|
||||||
return Array.from(this.windows.values()).sort((a, b) => a.zIndex - b.zIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMessage(event: string, data?: any) {
|
onMessage(event: string, data?: any) {
|
||||||
console.log(`[WindowService] 收到事件:`, event, data);
|
console.log(`[WindowService] 收到事件:`, event, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,68 +10,69 @@ import type {
|
|||||||
* 创建一个可观察对象,用于管理状态和事件。
|
* 创建一个可观察对象,用于管理状态和事件。
|
||||||
* @template T - 需要处理的状态类型
|
* @template T - 需要处理的状态类型
|
||||||
* @example
|
* @example
|
||||||
|
* interface Todos {
|
||||||
|
* id: number
|
||||||
|
* text: string
|
||||||
|
* done: boolean
|
||||||
|
* }
|
||||||
|
*
|
||||||
* interface AppState {
|
* interface AppState {
|
||||||
* count: number
|
* count: number
|
||||||
|
* todos: Todos[]
|
||||||
* user: {
|
* user: {
|
||||||
* name: string
|
* name: string
|
||||||
* age: number
|
* age: number
|
||||||
* }
|
* }
|
||||||
* items: number[]
|
* inc(): void
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* // 创建 ObservableImpl
|
|
||||||
* const obs = new ObservableImpl<AppState>({
|
* const obs = new ObservableImpl<AppState>({
|
||||||
* count: 0,
|
* count: 0,
|
||||||
* user: { name: 'Alice', age: 20 },
|
* todos: [],
|
||||||
* items: []
|
* user: { name: "Alice", age: 20 },
|
||||||
|
* inc() {
|
||||||
|
* this.count++ // ✅ this 指向 obs.state
|
||||||
|
* },
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // 1️⃣ 全量订阅
|
* // ================== 使用示例 ==================
|
||||||
* const unsubscribeAll = obs.subscribe(state => {
|
|
||||||
* console.log('全量订阅', state)
|
|
||||||
* }, { immediate: true })
|
|
||||||
*
|
*
|
||||||
* // 2️⃣ 单字段订阅
|
* // 1. 订阅整个 state
|
||||||
* const unsubscribeCount = obs.subscribeKey('count', ({ count }) => {
|
* obs.subscribe(state => {
|
||||||
* console.log('count 字段变化:', count)
|
* console.log("[全量订阅] state 更新:", state)
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // 3️⃣ 多字段订阅
|
* // 2. 订阅单个字段
|
||||||
* const unsubscribeUser = obs.subscribeKey(['user', 'count'], ({ user, count }) => {
|
* obs.subscribeKey("count", ({ count }) => {
|
||||||
* console.log('user 或 count 变化:', { user, count })
|
* console.log("[字段订阅] count 更新:", count)
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // 4️⃣ 修改属性
|
* // 3. 订阅多个字段
|
||||||
* obs.state.count = 1 // ✅ 会触发 count 和全量订阅
|
* obs.subscribeKey(["name", "age"] as (keyof AppState["user"])[], (user) => {
|
||||||
* obs.state.user.age = 21 // ✅ 深层对象修改触发 user 订阅
|
* console.log("[多字段订阅] user 更新:", user)
|
||||||
* obs.state.user.name = 'Bob'
|
|
||||||
* // 语法糖:解构赋值直接赋值触发通知
|
|
||||||
* const { count, user, items } = obs.toRefsProxy()
|
|
||||||
* count = 1 // 触发 Proxy set
|
|
||||||
* user.age = 18 // 深层对象 Proxy 支持
|
|
||||||
* items.push(42) // 数组方法拦截触发通知
|
|
||||||
*
|
|
||||||
* // 5️⃣ 数组方法触发
|
|
||||||
* obs.state.items.push(10) // ✅ push 会触发 items 的字段订阅
|
|
||||||
* obs.state.items.splice(0, 1)
|
|
||||||
*
|
|
||||||
* // 6️⃣ 批量修改(同一事件循环只触发一次通知)
|
|
||||||
* obs.patch({
|
|
||||||
* count: 2,
|
|
||||||
* user: { name: 'Charlie', age: 30 }
|
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // 7️⃣ 解构赋值访问对象属性仍然触发订阅
|
* // 4. 批量更新
|
||||||
* const { state } = obs
|
* obs.patch({ count: 10, user: { name: "Bob", age: 30 } })
|
||||||
* state.user.age = 31 // ✅ 会触发 user 订阅
|
|
||||||
*
|
*
|
||||||
* // 8️⃣ 取消订阅
|
* // 5. 方法里操作 state
|
||||||
* unsubscribeAll()
|
* obs.state.inc() // this.count++ → 相当于 obs.state.count++
|
||||||
* unsubscribeCount()
|
|
||||||
* unsubscribeUser()
|
|
||||||
*
|
*
|
||||||
* // 9️⃣ 销毁 ObservableImpl
|
* // 6. 数组操作
|
||||||
* obs.dispose()
|
* obs.subscribeKey("todos", ({ todos }) => {
|
||||||
|
* console.log("[数组订阅] todos 更新:", todos.map(t => t.text))
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* obs.state.todos.push({ id: 1, text: "Buy milk", done: false })
|
||||||
|
* obs.state.todos.push({ id: 2, text: "Read book", done: false })
|
||||||
|
* obs.state.todos[0].done = true
|
||||||
|
*
|
||||||
|
* // 7. 嵌套对象
|
||||||
|
* obs.subscribeKey("user", ({ user }) => {
|
||||||
|
* console.log("[嵌套订阅] user 更新:", user)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* obs.state.user.age++
|
||||||
*/
|
*/
|
||||||
export class ObservableImpl<T extends TNonFunctionProperties<T>> implements IObservable<T> {
|
export class ObservableImpl<T extends TNonFunctionProperties<T>> implements IObservable<T> {
|
||||||
/** Observable 状态对象,深层 Proxy */
|
/** Observable 状态对象,深层 Proxy */
|
||||||
@@ -80,8 +81,13 @@ export class ObservableImpl<T extends TNonFunctionProperties<T>> implements IObs
|
|||||||
/** 全量订阅函数集合 */
|
/** 全量订阅函数集合 */
|
||||||
private listeners: Set<TObservableListener<T>> = new Set()
|
private listeners: Set<TObservableListener<T>> = new Set()
|
||||||
|
|
||||||
/** 字段订阅函数集合 */
|
/**
|
||||||
private keyListeners: Map<keyof T, Set<Function>> = new Map()
|
* 字段订阅函数集合
|
||||||
|
* 新结构:
|
||||||
|
* Map<TObservableKeyListener, Array<keyof T>>
|
||||||
|
* 记录每个回调订阅的字段数组,保证多字段订阅 always 返回所有订阅字段值
|
||||||
|
*/
|
||||||
|
private keyListeners: Map<TObservableKeyListener<T, keyof T>, Array<keyof T>> = new Map()
|
||||||
|
|
||||||
/** 待通知的字段集合 */
|
/** 待通知的字段集合 */
|
||||||
private pendingKeys: Set<keyof T> = new Set()
|
private pendingKeys: Set<keyof T> = new Set()
|
||||||
@@ -92,166 +98,208 @@ export class ObservableImpl<T extends TNonFunctionProperties<T>> implements IObs
|
|||||||
/** 是否已销毁 */
|
/** 是否已销毁 */
|
||||||
private disposed = false
|
private disposed = false
|
||||||
|
|
||||||
|
/** 缓存 Proxy,避免重复包装 */
|
||||||
|
private proxyCache: WeakMap<object, TObservableState<unknown>> = new WeakMap()
|
||||||
|
|
||||||
constructor(initialState: TNonFunctionProperties<T>) {
|
constructor(initialState: TNonFunctionProperties<T>) {
|
||||||
// 创建深层响应式 Proxy
|
// 创建深层响应式 Proxy
|
||||||
this.state = this.makeReactive(initialState) as TObservableState<T>
|
this.state = this.makeReactive(initialState) as TObservableState<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建深层 Proxy,拦截 get/set */
|
/** 创建深层 Proxy,拦截 get/set/delete,并自动缓存 */
|
||||||
private makeReactive(obj: TNonFunctionProperties<T>): TObservableState<T> {
|
private makeReactive<O extends object>(obj: O): TObservableState<O> {
|
||||||
const handler: ProxyHandler<any> = {
|
// 非对象直接返回(包括 null 已被排除)
|
||||||
get: (target, prop: string | symbol, receiver) => {
|
if (typeof obj !== "object" || obj === null) {
|
||||||
const key = prop as keyof T
|
return obj as unknown as TObservableState<O>
|
||||||
const value = Reflect.get(target, key, receiver)
|
}
|
||||||
if (Array.isArray(value)) return this.wrapArray(value, key)
|
|
||||||
if (typeof value === 'object' && value !== null) return this.makeReactive(value)
|
// 如果已有 Proxy 缓存则直接返回
|
||||||
|
const cached = this.proxyCache.get(obj as object)
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached as TObservableState<O>
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler: ProxyHandler<O> = {
|
||||||
|
get: (target, prop, receiver) => {
|
||||||
|
const value = Reflect.get(target, prop, receiver) as unknown
|
||||||
|
// 不包装函数
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
// 对对象/数组继续进行响应式包装(递归)
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
return this.makeReactive(value as object)
|
||||||
|
}
|
||||||
return value
|
return value
|
||||||
},
|
},
|
||||||
set: (target, prop: string | symbol, value, receiver) => {
|
|
||||||
const key = prop as keyof T
|
set: (target, prop, value, receiver) => {
|
||||||
const oldValue = target[key]
|
// 读取旧值(使用 Record 以便类型安全访问属性)
|
||||||
if (oldValue !== value) {
|
const oldValue = (target as Record<PropertyKey, unknown>)[prop as PropertyKey] as unknown
|
||||||
target[key] = value
|
const result = Reflect.set(target, prop, value as unknown, receiver)
|
||||||
this.pendingKeys.add(key)
|
// 仅在值改变时触发通知(基于引用/原始值比较)
|
||||||
|
if (!this.disposed && oldValue !== (value as unknown)) {
|
||||||
|
this.pendingKeys.add(prop as keyof T)
|
||||||
this.scheduleNotify()
|
this.scheduleNotify()
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Proxy(obj, handler) as TObservableState<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 包装数组方法,使 push/pop 等触发通知 */
|
|
||||||
private wrapArray(arr: any[], parentKey: keyof T): any {
|
|
||||||
const self = this
|
|
||||||
const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'] as const
|
|
||||||
arrayMethods.forEach(method => {
|
|
||||||
const original = arr[method]
|
|
||||||
Object.defineProperty(arr, method, {
|
|
||||||
value: function (...args: any[]) {
|
|
||||||
const result = original.apply(this, args)
|
|
||||||
self.pendingKeys.add(parentKey)
|
|
||||||
self.scheduleNotify()
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
deleteProperty: (target, prop) => {
|
||||||
})
|
if (prop in target) {
|
||||||
})
|
// 使用 Reflect.deleteProperty 以保持一致性
|
||||||
return arr
|
const deleted = Reflect.deleteProperty(target, prop)
|
||||||
|
if (deleted && !this.disposed) {
|
||||||
|
this.pendingKeys.add(prop as keyof T)
|
||||||
|
this.scheduleNotify()
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 安排下一次通知 */
|
const proxy = new Proxy(obj, handler) as TObservableState<O>
|
||||||
|
this.proxyCache.set(obj as object, proxy as TObservableState<unknown>)
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安排下一次通知(微任务合并) */
|
||||||
private scheduleNotify(): void {
|
private scheduleNotify(): void {
|
||||||
if (!this.notifyScheduled && !this.disposed) {
|
if (!this.notifyScheduled && !this.disposed && this.pendingKeys.size > 0) {
|
||||||
this.notifyScheduled = true
|
this.notifyScheduled = true
|
||||||
Promise.resolve().then(() => this.flushNotify())
|
Promise.resolve().then(() => this.flushNotify())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行通知 */
|
/** 执行通知(聚合字段订阅并保证错误隔离) */
|
||||||
private flushNotify(): void {
|
private flushNotify(): void {
|
||||||
if (this.disposed) return
|
if (this.disposed) return
|
||||||
const keys = Array.from(this.pendingKeys)
|
|
||||||
this.pendingKeys.clear()
|
this.pendingKeys.clear()
|
||||||
this.notifyScheduled = false
|
this.notifyScheduled = false
|
||||||
|
|
||||||
// 全量订阅
|
// 全量订阅 —— 每个订阅单独 try/catch,避免一个错误阻塞其它订阅
|
||||||
for (const fn of this.listeners) {
|
for (const fn of this.listeners) {
|
||||||
fn(this.state)
|
try {
|
||||||
}
|
fn(this.state as unknown as T)
|
||||||
|
} catch (err) {
|
||||||
// 字段订阅
|
console.error("Observable listener error:", err)
|
||||||
const fnMap = new Map<Function, (keyof T)[]>()
|
|
||||||
for (const key of keys) {
|
|
||||||
const set = this.keyListeners.get(key)
|
|
||||||
if (!set) continue
|
|
||||||
for (const fn of set) {
|
|
||||||
if (!fnMap.has(fn)) fnMap.set(fn, [])
|
|
||||||
fnMap.get(fn)!.push(key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fnMap.forEach((subKeys, fn) => {
|
// ================== 字段订阅 ==================
|
||||||
const result = {} as Pick<T, typeof subKeys[number]>
|
// 遍历所有回调,每个回调都返回它订阅的字段(即使只有部分字段变化)
|
||||||
subKeys.forEach(k => (result[k] = this.state[k]))
|
this.keyListeners.forEach((subKeys, fn) => {
|
||||||
fn(result)
|
try {
|
||||||
|
// 构造 Pick<T, K> 风格的结果对象:结果类型为 Pick<T, (typeof subKeys)[number]>
|
||||||
|
const result = {} as Pick<T, (typeof subKeys)[number]>
|
||||||
|
subKeys.forEach(k => {
|
||||||
|
// 这里断言原因:state 的索引访问返回 unknown,但我们把它赋回到受限的 Pick 上
|
||||||
|
result[k] = (this.state as Record<keyof T, unknown>)[k] as T[(typeof k) & keyof T]
|
||||||
|
})
|
||||||
|
// 调用时类型上兼容 TObservableKeyListener<T, K>,因为我们传的是对应 key 的 Pick
|
||||||
|
fn(result as Pick<T, (typeof subKeys)[number]>)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Observable keyListener error:", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订阅整个状态变化 */
|
/** 订阅整个状态变化 */
|
||||||
subscribe(fn: TObservableListener<T>, options: { immediate?: boolean } = {}): () => void {
|
public subscribe(fn: TObservableListener<T>, options: { immediate?: boolean } = {}): () => void {
|
||||||
this.listeners.add(fn)
|
this.listeners.add(fn)
|
||||||
if (options.immediate) fn(this.state)
|
if (options.immediate) {
|
||||||
|
try {
|
||||||
|
fn(this.state as unknown as T)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Observable subscribe immediate error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners.delete(fn)
|
this.listeners.delete(fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订阅指定字段变化 */
|
/** 订阅指定字段变化(多字段订阅 always 返回所有字段值) */
|
||||||
subscribeKey<K extends keyof T>(
|
public subscribeKey<K extends keyof T>(
|
||||||
keys: K | K[],
|
keys: K | K[],
|
||||||
fn: TObservableKeyListener<T, K>,
|
fn: TObservableKeyListener<T, K>,
|
||||||
options: { immediate?: boolean } = {}
|
options: { immediate?: boolean } = {}
|
||||||
): () => void {
|
): () => void {
|
||||||
const keyArray = Array.isArray(keys) ? keys : [keys]
|
const keyArray = Array.isArray(keys) ? keys : [keys]
|
||||||
for (const key of keyArray) {
|
|
||||||
if (!this.keyListeners.has(key)) this.keyListeners.set(key, new Set())
|
|
||||||
this.keyListeners.get(key)!.add(fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ================== 存储回调和它订阅的字段数组 ==================
|
||||||
|
this.keyListeners.set(fn as TObservableKeyListener<T, keyof T>, keyArray as (keyof T)[])
|
||||||
|
|
||||||
|
// ================== 立即调用 ==================
|
||||||
if (options.immediate) {
|
if (options.immediate) {
|
||||||
const result = {} as Pick<T, K>
|
const result = {} as Pick<T, K>
|
||||||
keyArray.forEach(k => (result[k] = this.state[k]))
|
keyArray.forEach(k => {
|
||||||
|
result[k] = (this.state as Record<keyof T, unknown>)[k] as T[K]
|
||||||
|
})
|
||||||
|
try {
|
||||||
fn(result)
|
fn(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Observable subscribeKey immediate error:", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================== 返回取消订阅函数 ==================
|
||||||
return () => {
|
return () => {
|
||||||
for (const key of keyArray) {
|
this.keyListeners.delete(fn as TObservableKeyListener<T, keyof T>)
|
||||||
this.keyListeners.get(key)?.delete(fn)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量更新状态 */
|
/** 批量更新状态(避免重复 schedule) */
|
||||||
patch(values: Partial<T>): void {
|
public patch(values: Partial<T>): void {
|
||||||
|
let changed = false
|
||||||
for (const key in values) {
|
for (const key in values) {
|
||||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||||
const typedKey = key as keyof T
|
const typedKey = key as keyof T
|
||||||
this.state[typedKey] = values[typedKey]!
|
const oldValue = (this.state as Record<keyof T, unknown>)[typedKey]
|
||||||
this.pendingKeys.add(typedKey)
|
const newValue = values[typedKey] as unknown
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
(this.state as Record<keyof T, unknown>)[typedKey] = newValue
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.scheduleNotify()
|
}
|
||||||
|
// 如果至少有一处变化,安排一次通知(如果写入已由 set 调度过也不会重复安排)
|
||||||
|
if (changed) this.scheduleNotify()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 销毁 Observable 实例 */
|
/** 销毁 Observable 实例 */
|
||||||
dispose(): void {
|
public dispose(): void {
|
||||||
this.disposed = true
|
this.disposed = true
|
||||||
this.listeners.clear()
|
this.listeners.clear()
|
||||||
this.keyListeners.clear()
|
this.keyListeners.clear()
|
||||||
this.pendingKeys.clear()
|
this.pendingKeys.clear()
|
||||||
|
this.proxyCache = new WeakMap()
|
||||||
|
Object.freeze(this.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 语法糖:返回一个可解构赋值的 Proxy */
|
/** 语法糖:返回一个可解构赋值的 Proxy */
|
||||||
toRefsProxy(): { [K in keyof T]: T[K] } {
|
public toRefsProxy(): { [K in keyof T]: T[K] } {
|
||||||
const self = this
|
const self = this
|
||||||
return new Proxy({} as T, {
|
return new Proxy({} as { [K in keyof T]: T[K] }, {
|
||||||
get(_, prop: string | symbol) {
|
get(_, prop: string | symbol) {
|
||||||
const key = prop as keyof T
|
const key = prop as keyof T
|
||||||
return self.state[key]
|
return (self.state as Record<keyof T, unknown>)[key] as T[typeof key]
|
||||||
},
|
},
|
||||||
set(_, prop: string | symbol, value) {
|
set(_, prop: string | symbol, value) {
|
||||||
const key = prop as keyof T
|
const key = prop as keyof T
|
||||||
self.state[key] = value
|
;(self.state as Record<keyof T, unknown>)[key] = value as unknown
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
ownKeys() {
|
ownKeys() {
|
||||||
return Reflect.ownKeys(self.state)
|
return Reflect.ownKeys(self.state)
|
||||||
},
|
},
|
||||||
getOwnPropertyDescriptor(_, prop: string | symbol) {
|
getOwnPropertyDescriptor(_, _prop: string | symbol) {
|
||||||
return { enumerable: true, configurable: true }
|
return { enumerable: true, configurable: true }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ObservableImpl } from '@/core/state/impl/ObservableImpl.ts'
|
||||||
|
|
||||||
|
interface IGlobalStoreParams {
|
||||||
|
/** 桌面根dom ID,类似显示器 */
|
||||||
|
monitorDomId: string;
|
||||||
|
monitorWidth: number;
|
||||||
|
monitorHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const globalStore = new ObservableImpl<IGlobalStoreParams>({
|
||||||
|
monitorDomId: '#app',
|
||||||
|
monitorWidth: 0,
|
||||||
|
monitorHeight: 0
|
||||||
|
})
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { TWindowFormState } from '@/core/window/types/WindowFormTypes.ts'
|
||||||
|
|
||||||
/** 拖拽移动开始的回调 */
|
/** 拖拽移动开始的回调 */
|
||||||
type TDragStartCallback = (x: number, y: number) => void;
|
type TDragStartCallback = (x: number, y: number) => void;
|
||||||
/** 拖拽移动中的回调 */
|
/** 拖拽移动中的回调 */
|
||||||
@@ -16,9 +18,6 @@ type TResizeDirection =
|
|||||||
| 'bottom-left'
|
| 'bottom-left'
|
||||||
| 'bottom-right';
|
| 'bottom-right';
|
||||||
|
|
||||||
/** 窗口状态 */
|
|
||||||
type WindowState = 'default' | 'minimized' | 'maximized';
|
|
||||||
|
|
||||||
/** 元素边界 */
|
/** 元素边界 */
|
||||||
interface IElementRect {
|
interface IElementRect {
|
||||||
/** 宽度 */
|
/** 宽度 */
|
||||||
@@ -51,8 +50,8 @@ interface IDraggableResizableOptions {
|
|||||||
target: HTMLElement;
|
target: HTMLElement;
|
||||||
/** 拖拽句柄 */
|
/** 拖拽句柄 */
|
||||||
handle?: HTMLElement;
|
handle?: HTMLElement;
|
||||||
/** 拖拽边界或容器元素 */
|
/** 拖拽边界容器元素 */
|
||||||
boundary?: IBoundaryRect | HTMLElement;
|
boundaryElement?: HTMLElement;
|
||||||
/** 移动步进(网格吸附) */
|
/** 移动步进(网格吸附) */
|
||||||
snapGrid?: number;
|
snapGrid?: number;
|
||||||
/** 关键点吸附阈值 */
|
/** 关键点吸附阈值 */
|
||||||
@@ -86,6 +85,9 @@ interface IDraggableResizableOptions {
|
|||||||
onResizeMove?: (data: IResizeCallbackData) => void;
|
onResizeMove?: (data: IResizeCallbackData) => void;
|
||||||
/** 拖拽调整尺寸结束回调 */
|
/** 拖拽调整尺寸结束回调 */
|
||||||
onResizeEnd?: (data: IResizeCallbackData) => void;
|
onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||||
|
|
||||||
|
/** 窗口状态改变回调 */
|
||||||
|
onWindowStateChange?: (state: TWindowFormState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拖拽的范围边界 */
|
/** 拖拽的范围边界 */
|
||||||
@@ -107,7 +109,7 @@ interface IBoundaryRect {
|
|||||||
export class DraggableResizableWindow {
|
export class DraggableResizableWindow {
|
||||||
private handle?: HTMLElement;
|
private handle?: HTMLElement;
|
||||||
private target: HTMLElement;
|
private target: HTMLElement;
|
||||||
private boundary?: HTMLElement | IBoundaryRect;
|
private boundaryElement: HTMLElement;
|
||||||
private snapGrid: number;
|
private snapGrid: number;
|
||||||
private snapThreshold: number;
|
private snapThreshold: number;
|
||||||
private snapAnimation: boolean;
|
private snapAnimation: boolean;
|
||||||
@@ -121,8 +123,11 @@ export class DraggableResizableWindow {
|
|||||||
private onResizeMove?: (data: IResizeCallbackData) => void;
|
private onResizeMove?: (data: IResizeCallbackData) => void;
|
||||||
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
private onResizeEnd?: (data: IResizeCallbackData) => void;
|
||||||
|
|
||||||
|
private onWindowStateChange?: (state: TWindowFormState) => void;
|
||||||
|
|
||||||
private isDragging = false;
|
private isDragging = false;
|
||||||
private currentDirection: TResizeDirection | null = null;
|
private currentDirection: TResizeDirection | null = null;
|
||||||
|
private dragThreshold = 2; // 拖拽阈值 超过才开始真正的拖拽
|
||||||
|
|
||||||
private startX = 0;
|
private startX = 0;
|
||||||
private startY = 0;
|
private startY = 0;
|
||||||
@@ -147,20 +152,28 @@ export class DraggableResizableWindow {
|
|||||||
private maxWidth: number;
|
private maxWidth: number;
|
||||||
private maxHeight: number;
|
private maxHeight: number;
|
||||||
|
|
||||||
private containerRect?: DOMRect;
|
private containerRect: DOMRect;
|
||||||
private resizeObserver?: ResizeObserver;
|
private resizeObserver?: ResizeObserver;
|
||||||
private mutationObserver: MutationObserver;
|
private mutationObserver: MutationObserver;
|
||||||
private animationFrame?: number;
|
private animationFrame?: number;
|
||||||
|
|
||||||
private state: WindowState = 'default';
|
private _windowFormState: TWindowFormState = 'default';
|
||||||
private targetDefaultBounds: IElementRect;
|
/** 元素信息 */
|
||||||
private maximizedBounds?: IElementRect;
|
private targetBounds: IElementRect;
|
||||||
|
/** 最小化前的元素信息 */
|
||||||
|
private targetPreMinimizeBounds?: IElementRect;
|
||||||
|
/** 最大化前的元素信息 */
|
||||||
|
private targetPreMaximizedBounds?: IElementRect;
|
||||||
private taskbarElementId: string;
|
private taskbarElementId: string;
|
||||||
|
|
||||||
|
get windowFormState() {
|
||||||
|
return this._windowFormState;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(options: IDraggableResizableOptions) {
|
constructor(options: IDraggableResizableOptions) {
|
||||||
this.handle = options.handle;
|
this.handle = options.handle;
|
||||||
this.target = options.target;
|
this.target = options.target;
|
||||||
this.boundary = options.boundary;
|
this.boundaryElement = options.boundaryElement ?? document.body;
|
||||||
this.snapGrid = options.snapGrid ?? 1;
|
this.snapGrid = options.snapGrid ?? 1;
|
||||||
this.snapThreshold = options.snapThreshold ?? 0;
|
this.snapThreshold = options.snapThreshold ?? 0;
|
||||||
this.snapAnimation = options.snapAnimation ?? false;
|
this.snapAnimation = options.snapAnimation ?? false;
|
||||||
@@ -177,21 +190,29 @@ export class DraggableResizableWindow {
|
|||||||
this.maxHeight = options.maxHeight ?? window.innerHeight;
|
this.maxHeight = options.maxHeight ?? window.innerHeight;
|
||||||
this.onResizeMove = options.onResizeMove;
|
this.onResizeMove = options.onResizeMove;
|
||||||
this.onResizeEnd = options.onResizeEnd;
|
this.onResizeEnd = options.onResizeEnd;
|
||||||
|
this.onWindowStateChange = options.onWindowStateChange;
|
||||||
|
|
||||||
this.targetDefaultBounds = {
|
this.taskbarElementId = options.taskbarElementId;
|
||||||
|
|
||||||
|
this.target.style.position = "absolute";
|
||||||
|
this.target.style.left = '0px';
|
||||||
|
this.target.style.top = '0px';
|
||||||
|
this.target.style.transform = "translate(0px, 0px)";
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.targetBounds = {
|
||||||
width: this.target.offsetWidth,
|
width: this.target.offsetWidth,
|
||||||
height: this.target.offsetHeight,
|
height: this.target.offsetHeight,
|
||||||
top: this.target.offsetTop,
|
top: this.target.offsetTop,
|
||||||
left: this.target.offsetLeft,
|
left: this.target.offsetLeft,
|
||||||
};
|
};
|
||||||
this.taskbarElementId = options.taskbarElementId;
|
this.containerRect = this.boundaryElement.getBoundingClientRect();
|
||||||
|
const x = this.containerRect.width / 2 - this.target.offsetWidth / 2;
|
||||||
this.target.style.position = "absolute";
|
const y = this.containerRect.height / 2 - this.target.offsetHeight / 2;
|
||||||
this.target.style.left = `${this.target.offsetLeft}px`;
|
this.target.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
this.target.style.top = `${this.target.offsetTop}px`;
|
});
|
||||||
this.target.style.transform = "translate(0px, 0px)";
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
@@ -202,7 +223,7 @@ export class DraggableResizableWindow {
|
|||||||
this.target.addEventListener('mouseleave', this.onMouseLeave);
|
this.target.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
document.addEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
document.addEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
||||||
|
|
||||||
if (this.boundary instanceof HTMLElement) this.observeResize(this.boundary);
|
this.observeResize(this.boundaryElement);
|
||||||
|
|
||||||
this.mutationObserver = new MutationObserver(mutations => {
|
this.mutationObserver = new MutationObserver(mutations => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
@@ -216,11 +237,56 @@ export class DraggableResizableWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ---------------- 拖拽 ---------------- */
|
|
||||||
private onMouseDownDrag = (e: MouseEvent) => {
|
private onMouseDownDrag = (e: MouseEvent) => {
|
||||||
if (this.getResizeDirection(e)) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!this.handle?.contains(e.target as Node)) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains('btn')) return;
|
||||||
|
if (this.getResizeDirection(e)) return;
|
||||||
|
|
||||||
|
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._windowFormState === '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) => {
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.startX = e.clientX;
|
this.startX = e.clientX;
|
||||||
this.startY = e.clientY;
|
this.startY = e.clientY;
|
||||||
@@ -237,6 +303,7 @@ export class DraggableResizableWindow {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private onMouseMoveDragRAF = (e: MouseEvent) => {
|
private onMouseMoveDragRAF = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
this.dragDX = e.clientX - this.startX;
|
this.dragDX = e.clientX - this.startX;
|
||||||
this.dragDY = e.clientY - this.startY;
|
this.dragDY = e.clientY - this.startY;
|
||||||
if (!this.pendingDrag) {
|
if (!this.pendingDrag) {
|
||||||
@@ -263,8 +330,8 @@ export class DraggableResizableWindow {
|
|||||||
this.onDragMove?.(newX, newY);
|
this.onDragMove?.(newX, newY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onMouseUpDrag = () => {
|
private onMouseUpDrag = (e: MouseEvent) => {
|
||||||
console.log(111)
|
e.stopPropagation();
|
||||||
if (!this.isDragging) return;
|
if (!this.isDragging) return;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
|
|
||||||
@@ -273,12 +340,12 @@ export class DraggableResizableWindow {
|
|||||||
if (this.snapAnimation) {
|
if (this.snapAnimation) {
|
||||||
this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => {
|
this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => {
|
||||||
this.onDragEnd?.(snapped.x, snapped.y);
|
this.onDragEnd?.(snapped.x, snapped.y);
|
||||||
this.updateDefaultBounds(snapped.x, snapped.y);
|
this.updateTargetBounds(snapped.x, snapped.y);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.applyPosition(snapped.x, snapped.y, true);
|
this.applyPosition(snapped.x, snapped.y, true);
|
||||||
this.onDragEnd?.(snapped.x, snapped.y);
|
this.onDragEnd?.(snapped.x, snapped.y);
|
||||||
this.updateDefaultBounds(snapped.x, snapped.y);
|
this.updateTargetBounds(snapped.x, snapped.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.removeEventListener('mousemove', this.onMouseMoveDragRAF);
|
document.removeEventListener('mousemove', this.onMouseMoveDragRAF);
|
||||||
@@ -312,27 +379,21 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyBoundary() {
|
private applyBoundary() {
|
||||||
if (!this.boundary || this.allowOverflow) return;
|
if (this.allowOverflow) return;
|
||||||
let { x, y } = { x: this.currentX, y: this.currentY };
|
let { x, y } = { x: this.currentX, y: this.currentY };
|
||||||
|
|
||||||
if (this.boundary instanceof HTMLElement && this.containerRect) {
|
|
||||||
const rect = this.target.getBoundingClientRect();
|
const rect = this.target.getBoundingClientRect();
|
||||||
x = Math.min(Math.max(x, 0), this.containerRect.width - rect.width);
|
x = Math.min(Math.max(x, 0), this.containerRect.width - rect.width);
|
||||||
y = Math.min(Math.max(y, 0), this.containerRect.height - rect.height);
|
y = Math.min(Math.max(y, 0), this.containerRect.height - rect.height);
|
||||||
} else if (!(this.boundary instanceof HTMLElement) && this.boundary) {
|
|
||||||
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.currentX = x;
|
||||||
|
this.currentY = y;
|
||||||
this.applyPosition(x, y, false);
|
this.applyPosition(x, y, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,27 +409,26 @@ export class DraggableResizableWindow {
|
|||||||
|
|
||||||
private getSnapPoints() {
|
private getSnapPoints() {
|
||||||
const snapPoints = { x: [] as number[], y: [] as number[] };
|
const snapPoints = { x: [] as number[], y: [] as number[] };
|
||||||
if (this.boundary instanceof HTMLElement && this.containerRect) {
|
|
||||||
const rect = this.target.getBoundingClientRect();
|
const rect = this.target.getBoundingClientRect();
|
||||||
snapPoints.x = [0, this.containerRect.width - rect.width];
|
snapPoints.x = [0, this.containerRect.width - rect.width];
|
||||||
snapPoints.y = [0, this.containerRect.height - rect.height];
|
snapPoints.y = [0, this.containerRect.height - rect.height];
|
||||||
} else if (this.boundary && !(this.boundary instanceof HTMLElement)) {
|
|
||||||
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;
|
return snapPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ---------------- 缩放 ---------------- */
|
|
||||||
private onMouseDownResize = (e: MouseEvent) => {
|
private onMouseDownResize = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
const dir = this.getResizeDirection(e);
|
const dir = this.getResizeDirection(e);
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
e.preventDefault();
|
|
||||||
this.startResize(e, dir);
|
this.startResize(e, dir);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onMouseLeave = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.updateCursor(null);
|
||||||
|
};
|
||||||
|
|
||||||
private startResize(e: MouseEvent, dir: TResizeDirection) {
|
private startResize(e: MouseEvent, dir: TResizeDirection) {
|
||||||
this.currentDirection = dir;
|
this.currentDirection = dir;
|
||||||
const rect = this.target.getBoundingClientRect();
|
const rect = this.target.getBoundingClientRect();
|
||||||
@@ -389,6 +449,7 @@ export class DraggableResizableWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onResizeDragRAF = (e: MouseEvent) => {
|
private onResizeDragRAF = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
this.resizeDX = e.clientX - this.startX;
|
this.resizeDX = e.clientX - this.startX;
|
||||||
this.resizeDY = e.clientY - this.startY;
|
this.resizeDY = e.clientY - this.startY;
|
||||||
if (!this.pendingResize) {
|
if (!this.pendingResize) {
|
||||||
@@ -422,25 +483,63 @@ 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.target.style.width = `${newWidth}px`;
|
|
||||||
this.target.style.height = `${newHeight}px`;
|
|
||||||
this.applyPosition(newX, newY, false);
|
|
||||||
|
|
||||||
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 onResizeEndHandler = () => {
|
// 应用尺寸调整边界
|
||||||
|
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));
|
||||||
|
newHeight = Math.max(this.minHeight, Math.min(this.maxHeight, newHeight));
|
||||||
|
|
||||||
|
// 边界限制
|
||||||
|
if (this.allowOverflow) {
|
||||||
|
this.currentX = newX;
|
||||||
|
this.currentY = newY;
|
||||||
|
this.target.style.width = `${newWidth}px`;
|
||||||
|
this.target.style.height = `${newHeight}px`;
|
||||||
|
this.applyPosition(newX, newY, false);
|
||||||
|
return {
|
||||||
|
left: newX,
|
||||||
|
top: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newX = Math.min(Math.max(0, newX), this.containerRect.width - newWidth);
|
||||||
|
newY = Math.min(Math.max(0, newY), this.containerRect.height - newHeight);
|
||||||
|
|
||||||
|
this.currentX = newX;
|
||||||
|
this.currentY = newY;
|
||||||
|
this.target.style.width = `${newWidth}px`;
|
||||||
|
this.target.style.height = `${newHeight}px`;
|
||||||
|
this.applyPosition(newX, newY, false);
|
||||||
|
return {
|
||||||
|
left: newX,
|
||||||
|
top: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResizeEndHandler = (e?: MouseEvent) => {
|
||||||
|
e?.stopPropagation();
|
||||||
if (!this.currentDirection) return;
|
if (!this.currentDirection) return;
|
||||||
this.onResizeEnd?.({
|
this.onResizeEnd?.({
|
||||||
width: this.target.offsetWidth,
|
width: this.target.offsetWidth,
|
||||||
@@ -449,7 +548,7 @@ export class DraggableResizableWindow {
|
|||||||
top: this.currentY,
|
top: this.currentY,
|
||||||
direction: this.currentDirection,
|
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.currentDirection = null;
|
||||||
this.updateCursor(null);
|
this.updateCursor(null);
|
||||||
document.removeEventListener('mousemove', this.onResizeDragRAF);
|
document.removeEventListener('mousemove', this.onResizeDragRAF);
|
||||||
@@ -458,7 +557,7 @@ export class DraggableResizableWindow {
|
|||||||
|
|
||||||
private getResizeDirection(e: MouseEvent): TResizeDirection | null {
|
private getResizeDirection(e: MouseEvent): TResizeDirection | null {
|
||||||
const rect = this.target.getBoundingClientRect();
|
const rect = this.target.getBoundingClientRect();
|
||||||
const offset = 8;
|
const offset = 4;
|
||||||
const x = e.clientX;
|
const x = e.clientX;
|
||||||
const y = e.clientY;
|
const y = e.clientY;
|
||||||
const top = y >= rect.top && y <= rect.top + offset;
|
const top = y >= rect.top && y <= rect.top + offset;
|
||||||
@@ -488,51 +587,142 @@ export class DraggableResizableWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onDocumentMouseMoveCursor = (e: MouseEvent) => {
|
private onDocumentMouseMoveCursor = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
if (this.currentDirection || this.isDragging) return;
|
if (this.currentDirection || this.isDragging) return;
|
||||||
const dir = this.getResizeDirection(e);
|
const dir = this.getResizeDirection(e);
|
||||||
this.updateCursor(dir);
|
this.updateCursor(dir);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ---------------- 最小化/最大化 ---------------- */
|
// 最小化到任务栏
|
||||||
public minimize() {
|
public minimize() {
|
||||||
if (this.state === 'minimized') return;
|
if (this._windowFormState === 'minimized') return;
|
||||||
this.state = 'minimized';
|
this.targetPreMinimizeBounds = { ...this.targetBounds }
|
||||||
const taskbar = document.getElementById(this.taskbarElementId);
|
this._windowFormState = 'minimized';
|
||||||
if (!taskbar) return;
|
|
||||||
|
const taskbar = document.querySelector(this.taskbarElementId);
|
||||||
|
if (!taskbar) throw new Error('任务栏元素未找到');
|
||||||
|
|
||||||
const rect = taskbar.getBoundingClientRect();
|
const rect = taskbar.getBoundingClientRect();
|
||||||
this.animateTo(rect.left, rect.top, 200);
|
const startX = this.currentX;
|
||||||
this.target.style.width = '0px';
|
const startY = this.currentY;
|
||||||
this.target.style.height = '0px';
|
const startW = this.target.offsetWidth;
|
||||||
|
const startH = this.target.offsetHeight;
|
||||||
|
|
||||||
|
this.animateWindow(startX, startY, startW, startH, rect.left, rect.top, rect.width, rect.height, 400, () => {
|
||||||
|
this.target.style.display = 'none';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 最大化 */
|
||||||
public maximize() {
|
public maximize() {
|
||||||
if (this.state === 'maximized') return;
|
if (this._windowFormState === 'maximized') return;
|
||||||
this.state = 'maximized';
|
this.targetPreMaximizedBounds = { ...this.targetBounds }
|
||||||
const bounds = { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
|
this._windowFormState = 'maximized';
|
||||||
this.maximizedBounds = bounds;
|
|
||||||
this.animateTo(bounds.left, bounds.top, 200);
|
const rect = this.target.getBoundingClientRect();
|
||||||
this.target.style.width = `${bounds.width}px`;
|
|
||||||
this.target.style.height = `${bounds.height}px`;
|
const startX = this.currentX;
|
||||||
|
const startY = this.currentY;
|
||||||
|
const startW = rect.width;
|
||||||
|
const startH = rect.height;
|
||||||
|
|
||||||
|
const targetX = 0;
|
||||||
|
const targetY = 0;
|
||||||
|
const targetW = this.containerRect?.width ?? window.innerWidth;
|
||||||
|
const targetH = this.containerRect?.height ?? window.innerHeight;
|
||||||
|
|
||||||
|
this.animateWindow(startX, startY, startW, startH, targetX, targetY, targetW, targetH, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
public restore() {
|
/** 恢复到默认窗体状态 */
|
||||||
if (this.state === 'default') return;
|
public restore(onComplete?: () => void) {
|
||||||
this.state = 'default';
|
if (this._windowFormState === 'default') return;
|
||||||
const b = this.targetDefaultBounds;
|
let b: IElementRect;
|
||||||
this.animateTo(b.left, b.top, 200);
|
if ((this._windowFormState as TWindowFormState) === 'minimized' && this.targetPreMinimizeBounds) {
|
||||||
this.target.style.width = `${b.width}px`;
|
// 最小化恢复,恢复到最小化前的状态
|
||||||
this.target.style.height = `${b.height}px`;
|
b = this.targetPreMinimizeBounds;
|
||||||
|
} else if ((this._windowFormState as TWindowFormState) === 'maximized' && this.targetPreMaximizedBounds) {
|
||||||
|
// 最大化恢复,恢复到最大化前的默认状态
|
||||||
|
b = this.targetPreMaximizedBounds;
|
||||||
|
} else {
|
||||||
|
b = this.targetBounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateDefaultBounds(left: number, top: number, width?: number, height?: number) {
|
this._windowFormState = 'default';
|
||||||
this.targetDefaultBounds = {
|
|
||||||
|
this.target.style.display = 'block';
|
||||||
|
|
||||||
|
const startX = this.currentX;
|
||||||
|
const startY = this.currentY;
|
||||||
|
const startW = this.target.offsetWidth;
|
||||||
|
const startH = this.target.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.target.style.width = `${w}px`;
|
||||||
|
this.target.style.height = `${h}px`;
|
||||||
|
this.applyPosition(x, y, false);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
this.target.style.width = `${targetW}px`;
|
||||||
|
this.target.style.height = `${targetH}px`;
|
||||||
|
this.applyPosition(targetX, targetY, true);
|
||||||
|
onComplete?.();
|
||||||
|
this.onWindowStateChange?.(this._windowFormState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTargetBounds(left: number, top: number, width?: number, height?: number) {
|
||||||
|
this.targetBounds = {
|
||||||
left, top,
|
left, top,
|
||||||
width: width ?? this.target.offsetWidth,
|
width: width ?? this.target.offsetWidth,
|
||||||
height: height ?? this.target.offsetHeight
|
height: height ?? this.target.offsetHeight
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ---------------- Resize Observer ---------------- */
|
/** 监听元素变化 */
|
||||||
private observeResize(element: HTMLElement) {
|
private observeResize(element: HTMLElement) {
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
this.containerRect = element.getBoundingClientRect();
|
this.containerRect = element.getBoundingClientRect();
|
||||||
@@ -540,8 +730,11 @@ export class DraggableResizableWindow {
|
|||||||
this.resizeObserver.observe(element);
|
this.resizeObserver.observe(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ---------------- 销毁 ---------------- */
|
/**
|
||||||
|
* 销毁实例
|
||||||
|
*/
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
try {
|
||||||
if (this.handle) this.handle.removeEventListener('mousedown', this.onMouseDownDrag);
|
if (this.handle) this.handle.removeEventListener('mousedown', this.onMouseDownDrag);
|
||||||
this.target.removeEventListener('mousedown', this.onMouseDownResize);
|
this.target.removeEventListener('mousedown', this.onMouseDownResize);
|
||||||
document.removeEventListener('mousemove', this.onMouseMoveDragRAF);
|
document.removeEventListener('mousemove', this.onMouseMoveDragRAF);
|
||||||
@@ -549,10 +742,11 @@ export class DraggableResizableWindow {
|
|||||||
document.removeEventListener('mousemove', this.onResizeDragRAF);
|
document.removeEventListener('mousemove', this.onResizeDragRAF);
|
||||||
document.removeEventListener('mouseup', this.onResizeEndHandler);
|
document.removeEventListener('mouseup', this.onResizeEndHandler);
|
||||||
document.removeEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
document.removeEventListener('mousemove', this.onDocumentMouseMoveCursor);
|
||||||
|
document.removeEventListener('mousemove', this.checkDragStart);
|
||||||
|
document.removeEventListener('mouseup', this.cancelPendingDrag);
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
this.mutationObserver.disconnect();
|
this.mutationObserver.disconnect();
|
||||||
cancelAnimationFrame(this.animationFrame ?? 0);
|
cancelAnimationFrame(this.animationFrame ?? 0);
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onMouseLeave = () => { this.updateCursor(null); };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { IProcess } from '@/core/process/IProcess.ts'
|
import type { IProcess } from '@/core/process/IProcess.ts'
|
||||||
|
import type { TWindowFormState } from '@/core/window/types/WindowFormTypes.ts'
|
||||||
|
import type { IDestroyable } from '@/core/common/types/IDestroyable.ts'
|
||||||
|
|
||||||
export interface IWindowForm {
|
export interface IWindowForm extends IDestroyable {
|
||||||
|
/** 窗体id */
|
||||||
get id(): string;
|
get id(): string;
|
||||||
|
/** 窗体所属的进程 */
|
||||||
get proc(): IProcess | undefined;
|
get proc(): IProcess | undefined;
|
||||||
|
/** 窗体元素 */
|
||||||
|
get windowFormEle(): HTMLElement;
|
||||||
|
/** 窗体状态 */
|
||||||
|
get windowFormState(): TWindowFormState;
|
||||||
}
|
}
|
||||||
@@ -3,86 +3,112 @@ import XSystem from '../../XSystem.ts'
|
|||||||
import type { IProcess } from '@/core/process/IProcess.ts'
|
import type { IProcess } from '@/core/process/IProcess.ts'
|
||||||
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
import type { IWindowForm } from '@/core/window/IWindowForm.ts'
|
||||||
import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts'
|
import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts'
|
||||||
import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
import type { TWindowFormState, WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
||||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
|
||||||
import { DraggableResizable } from '@/core/utils/DraggableResizable.ts'
|
|
||||||
import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts'
|
import { DraggableResizableWindow } from '@/core/utils/DraggableResizableWindow.ts'
|
||||||
|
import '../ui/WindowFormElement.ts'
|
||||||
|
import { wfem } from '@/core/events/WindowFormEventManager.ts'
|
||||||
|
import type { IObservable } from '@/core/state/IObservable.ts'
|
||||||
|
import { ObservableImpl } from '@/core/state/impl/ObservableImpl.ts'
|
||||||
|
|
||||||
|
export interface IWindowFormDataState {
|
||||||
|
/** 窗体id */
|
||||||
|
id: string;
|
||||||
|
/** 窗体进程id */
|
||||||
|
procId: string;
|
||||||
|
/** 进程名称唯一 */
|
||||||
|
name: string;
|
||||||
|
/** 窗体标题 */
|
||||||
|
title: string;
|
||||||
|
/** 窗体位置x (左上角) */
|
||||||
|
x: number;
|
||||||
|
/** 窗体位置y (左上角) */
|
||||||
|
y: number;
|
||||||
|
/** 窗体宽度 */
|
||||||
|
width: number;
|
||||||
|
/** 窗体高度 */
|
||||||
|
height: number;
|
||||||
|
/** 窗体状态 'default' | 'minimized' | 'maximized' */
|
||||||
|
state: TWindowFormState;
|
||||||
|
/** 窗体是否已关闭 */
|
||||||
|
closed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default class WindowFormImpl implements IWindowForm {
|
export default class WindowFormImpl implements IWindowForm {
|
||||||
private readonly _id: string = uuidV4();
|
private readonly _id: string = uuidV4()
|
||||||
private readonly _procId: string;
|
private readonly _proc: IProcess
|
||||||
private pos: WindowFormPos = { x: 0, y: 0 };
|
private readonly _data: IObservable<IWindowFormDataState>
|
||||||
private width: number = 0;
|
private dom: HTMLElement
|
||||||
private height: number = 0;
|
private drw: DraggableResizableWindow
|
||||||
|
|
||||||
public get id() {
|
public get id() {
|
||||||
return this._id;
|
return this._id
|
||||||
}
|
}
|
||||||
public get proc() {
|
public get proc() {
|
||||||
return processManager.findProcessById(this._procId)
|
return this._proc
|
||||||
}
|
}
|
||||||
private get desktopRootDom() {
|
private get desktopRootDom() {
|
||||||
return XSystem.instance.desktopRootDom;
|
return XSystem.instance.desktopRootDom
|
||||||
|
}
|
||||||
|
public get windowFormEle() {
|
||||||
|
return this.dom
|
||||||
|
}
|
||||||
|
public get windowFormState() {
|
||||||
|
return this.drw.windowFormState
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(proc: IProcess, config: IWindowFormConfig) {
|
constructor(proc: IProcess, config: IWindowFormConfig) {
|
||||||
this._procId = proc.id;
|
this._proc = proc
|
||||||
console.log('WindowForm')
|
console.log('WindowForm')
|
||||||
this.pos = {
|
|
||||||
|
this._data = new ObservableImpl<IWindowFormDataState>({
|
||||||
|
id: this.id,
|
||||||
|
procId: proc.id,
|
||||||
|
name: proc.processInfo.name,
|
||||||
|
title: config.title ?? '未命名',
|
||||||
x: config.left ?? 0,
|
x: config.left ?? 0,
|
||||||
y: config.top ?? 0
|
y: config.top ?? 0,
|
||||||
}
|
width: config.width ?? 200,
|
||||||
this.width = config.width ?? 0;
|
height: config.height ?? 100,
|
||||||
this.height = config.height ?? 0;
|
state: 'default',
|
||||||
|
closed: false,
|
||||||
this.createWindowFrom();
|
|
||||||
}
|
|
||||||
|
|
||||||
public createWindowFrom() {
|
|
||||||
const dom = document.createElement('div');
|
|
||||||
dom.style.position = 'absolute';
|
|
||||||
dom.style.left = `${this.pos.x}px`;
|
|
||||||
dom.style.top = `${this.pos.y}px`;
|
|
||||||
dom.style.width = `${this.width}px`;
|
|
||||||
dom.style.height = `${this.height}px`;
|
|
||||||
dom.style.zIndex = '100';
|
|
||||||
dom.style.backgroundColor = 'white';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.style.width = '100%';
|
|
||||||
div.style.height = '20px';
|
|
||||||
div.style.backgroundColor = 'red';
|
|
||||||
dom.appendChild(div)
|
|
||||||
const bt1 = document.createElement('button');
|
|
||||||
bt1.innerText = '最小化';
|
|
||||||
bt1.addEventListener('click', () => {
|
|
||||||
win.minimize();
|
|
||||||
})
|
|
||||||
div.appendChild(bt1)
|
|
||||||
const bt2 = document.createElement('button');
|
|
||||||
bt2.innerText = '最大化';
|
|
||||||
bt2.addEventListener('click', () => {
|
|
||||||
win.maximize();
|
|
||||||
})
|
|
||||||
div.appendChild(bt2)
|
|
||||||
const bt3 = document.createElement('button');
|
|
||||||
bt3.innerText = '关闭';
|
|
||||||
bt3.addEventListener('click', () => {
|
|
||||||
this.desktopRootDom.removeChild(dom)
|
|
||||||
win.destroy();
|
|
||||||
this.proc?.windowForms.delete(this.id);
|
|
||||||
processManager.removeProcess(this.proc!)
|
|
||||||
})
|
|
||||||
div.appendChild(bt3)
|
|
||||||
|
|
||||||
const win = new DraggableResizableWindow({
|
|
||||||
target: dom,
|
|
||||||
handle: div,
|
|
||||||
snapAnimation: true,
|
|
||||||
snapThreshold: 20,
|
|
||||||
boundary: document.body,
|
|
||||||
taskbarElementId: '#taskbar',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.desktopRootDom.appendChild(dom);
|
this.initEvent()
|
||||||
|
this.createWindowFrom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initEvent() {
|
||||||
|
this._data.subscribeKey('closed', (state) => {
|
||||||
|
console.log('closed', state)
|
||||||
|
this.closeWindowForm()
|
||||||
|
this._proc.closeWindowForm(this.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWindowFrom() {
|
||||||
|
const wf = document.createElement('window-form-element')
|
||||||
|
wf.wid = this.id
|
||||||
|
wf.wfData = this._data
|
||||||
|
wf.title = this._data.state.title
|
||||||
|
wf.dragContainer = document.body
|
||||||
|
wf.snapDistance = 20
|
||||||
|
wf.taskbarElementId = '#taskbar'
|
||||||
|
this.dom = wf
|
||||||
|
this.desktopRootDom.appendChild(this.dom)
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
wfem.notifyEvent('windowFormCreated')
|
||||||
|
wfem.notifyEvent('windowFormFocus', this.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeWindowForm() {
|
||||||
|
this.desktopRootDom.removeChild(this.dom)
|
||||||
|
this._data.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
public minimize() {}
|
||||||
|
public maximize() {}
|
||||||
|
public restore() {}
|
||||||
|
|
||||||
|
public destroy() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ export interface WindowFormPos {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 窗口状态 */
|
||||||
|
export type TWindowFormState = 'default' | 'minimized' | 'maximized';
|
||||||
|
|||||||
904
src/core/window/ui/WindowFormElement.ts
Normal file
904
src/core/window/ui/WindowFormElement.ts
Normal file
@@ -0,0 +1,904 @@
|
|||||||
|
import { LitElement, html, css, unsafeCSS } from 'lit'
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import wfStyle from './css/wf.scss?inline'
|
||||||
|
import type { TWindowFormState } from '@/core/window/types/WindowFormTypes.ts'
|
||||||
|
import { wfem } from '@/core/events/WindowFormEventManager.ts'
|
||||||
|
import type { IObservable } from '@/core/state/IObservable.ts'
|
||||||
|
import type { IWindowFormDataState } from '@/core/window/impl/WindowFormImpl.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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('window-form-element')
|
||||||
|
export class WindowFormElement extends LitElement {
|
||||||
|
// ==== 公共属性 ====
|
||||||
|
@property({ type: String }) wid: string
|
||||||
|
@property({ type: String }) override title = 'Window'
|
||||||
|
@property({ type: Boolean }) resizable = true
|
||||||
|
@property({ type: Boolean }) minimizable = true
|
||||||
|
@property({ type: Boolean }) maximizable = true
|
||||||
|
@property({ type: Boolean }) closable = true
|
||||||
|
@property({ type: Boolean, reflect: true }) focused: boolean = true
|
||||||
|
@property({ type: String, reflect: true }) windowFormState: TWindowFormState = 'default'
|
||||||
|
@property({ type: Object }) dragContainer?: HTMLElement
|
||||||
|
@property({ type: Boolean }) allowOverflow = true // 允许窗口超出容器
|
||||||
|
@property({ type: Number }) snapDistance = 0 // 吸附距离
|
||||||
|
@property({ type: Boolean }) snapAnimation = true // 吸附动画
|
||||||
|
@property({ type: Number }) snapAnimationDuration = 300 // 吸附动画时长 ms
|
||||||
|
@property({ type: Number }) maxWidth?: number = Infinity
|
||||||
|
@property({ type: Number }) minWidth?: number = 0
|
||||||
|
@property({ type: Number }) maxHeight?: number = Infinity
|
||||||
|
@property({ type: Number }) minHeight?: number = 0
|
||||||
|
@property({ type: String }) taskbarElementId?: string
|
||||||
|
@property({ type: Object }) wfData: IObservable<IWindowFormDataState>;
|
||||||
|
|
||||||
|
private _listeners: Array<{ type: string; original: Function; wrapped: EventListener }> = []
|
||||||
|
|
||||||
|
// ==== 拖拽/缩放状态(内部变量,不触发渲染) ====
|
||||||
|
private dragging = false
|
||||||
|
private resizeDir: TResizeDirection | null = null
|
||||||
|
private startX = 0
|
||||||
|
private startY = 0
|
||||||
|
private startWidth = 0
|
||||||
|
private startHeight = 0
|
||||||
|
private startX_host = 0
|
||||||
|
private startY_host = 0
|
||||||
|
|
||||||
|
private x = 0
|
||||||
|
private y = 0
|
||||||
|
private preX = 0
|
||||||
|
private preY = 0
|
||||||
|
private width = 640
|
||||||
|
private height = 360
|
||||||
|
private animationFrame?: number
|
||||||
|
private resizing = false
|
||||||
|
|
||||||
|
// private get x() {
|
||||||
|
// return this.wfData.state.x
|
||||||
|
// }
|
||||||
|
// private set x(value: number) {
|
||||||
|
// this.wfData.patch({ x: value })
|
||||||
|
// }
|
||||||
|
// private get y() {
|
||||||
|
// return this.wfData.state.y
|
||||||
|
// }
|
||||||
|
// private set y(value: number) {
|
||||||
|
// this.wfData.patch({ y: value })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private windowFormState: TWindowFormState = 'default';
|
||||||
|
/** 元素信息 */
|
||||||
|
private targetBounds: IElementRect
|
||||||
|
/** 最小化前的元素信息 */
|
||||||
|
private targetPreMinimizeBounds?: IElementRect
|
||||||
|
/** 最大化前的元素信息 */
|
||||||
|
private targetPreMaximizedBounds?: IElementRect
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
${unsafeCSS(wfStyle)}
|
||||||
|
`
|
||||||
|
|
||||||
|
protected override createRenderRoot() {
|
||||||
|
const root = this.attachShadow({ mode: 'closed' })
|
||||||
|
const sheet = new CSSStyleSheet()
|
||||||
|
sheet.replaceSync(wfStyle)
|
||||||
|
root.adoptedStyleSheets = [sheet]
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
public addManagedEventListener<K extends keyof WindowFormEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler: (this: WindowFormElement, ev: WindowFormEventMap[K]) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void
|
||||||
|
public addManagedEventListener<K extends keyof WindowFormEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler: (ev: WindowFormEventMap[K]) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void
|
||||||
|
/**
|
||||||
|
* 添加受管理的事件监听
|
||||||
|
* @param type 事件类型
|
||||||
|
* @param handler 事件处理函数
|
||||||
|
*/
|
||||||
|
public addManagedEventListener<K extends keyof WindowFormEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler:
|
||||||
|
| ((this: WindowFormElement, ev: WindowFormEventMap[K]) => any)
|
||||||
|
| ((ev: WindowFormEventMap[K]) => any),
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
) {
|
||||||
|
const wrapped: EventListener = (ev: Event) => {
|
||||||
|
(handler as any).call(this, ev as WindowFormEventMap[K])
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addEventListener(type, wrapped, options)
|
||||||
|
this._listeners.push({ type, original: handler, wrapped })
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeManagedEventListener<K extends keyof WindowFormEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler:
|
||||||
|
| ((this: WindowFormElement, ev: WindowFormEventMap[K]) => any)
|
||||||
|
| ((ev: WindowFormEventMap[K]) => any)
|
||||||
|
) {
|
||||||
|
const index = this._listeners.findIndex(
|
||||||
|
l => l.type === type && l.original === handler
|
||||||
|
)
|
||||||
|
if (index !== -1) {
|
||||||
|
const { type: t, wrapped } = this._listeners[index]
|
||||||
|
this.removeEventListener(t, wrapped)
|
||||||
|
this._listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除所有受管理事件监听
|
||||||
|
*/
|
||||||
|
public removeAllManagedListeners() {
|
||||||
|
for (const { type, wrapped } of this._listeners) {
|
||||||
|
this.removeEventListener(type, wrapped)
|
||||||
|
}
|
||||||
|
this._listeners = []
|
||||||
|
}
|
||||||
|
|
||||||
|
override firstUpdated() {
|
||||||
|
wfem.addEventListener('windowFormFocus', this.windowFormFocusFun)
|
||||||
|
window.addEventListener('pointerup', this.onPointerUp)
|
||||||
|
window.addEventListener('pointermove', this.onPointerMove)
|
||||||
|
this.addEventListener('pointerdown', this.toggleFocus)
|
||||||
|
|
||||||
|
const container = this.dragContainer || document.body
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
this.x = containerRect.width / 2 - this.width / 2
|
||||||
|
this.y = containerRect.height / 2 - this.height / 2
|
||||||
|
this.style.width = `${this.width}px`
|
||||||
|
this.style.height = `${this.height}px`
|
||||||
|
this.style.transform = `translate(${this.x}px, ${this.y}px)`
|
||||||
|
|
||||||
|
this.targetBounds = {
|
||||||
|
width: this.offsetWidth,
|
||||||
|
height: this.offsetHeight,
|
||||||
|
top: this.x,
|
||||||
|
left: this.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback()
|
||||||
|
window.removeEventListener('pointerup', this.onPointerUp)
|
||||||
|
window.removeEventListener('pointermove', this.onPointerMove)
|
||||||
|
this.removeEventListener('pointerdown', this.toggleFocus)
|
||||||
|
wfem.removeEventListener('windowFormFocus', this.windowFormFocusFun)
|
||||||
|
this.removeAllManagedListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private windowFormFocusFun = (id: string) => {
|
||||||
|
if (id === this.wid) {
|
||||||
|
this.focused = true
|
||||||
|
} else {
|
||||||
|
this.focused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleFocus = () => {
|
||||||
|
this.focused = !this.focused
|
||||||
|
wfem.notifyEvent('windowFormFocus', this.wid)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('update:focused', {
|
||||||
|
detail: this.focused,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 拖拽 ======
|
||||||
|
private onTitlePointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||||
|
if ((e.target as HTMLElement).closest('.controls')) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.dragging = true
|
||||||
|
this.startX = e.clientX
|
||||||
|
this.startY = e.clientY
|
||||||
|
this.preX = this.x
|
||||||
|
this.preY = this.y
|
||||||
|
this.setPointerCapture?.(e.pointerId)
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:dragStart', {
|
||||||
|
detail: { x: this.x, y: this.y },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (this.dragging) {
|
||||||
|
const dx = e.clientX - this.startX
|
||||||
|
const dy = e.clientY - this.startY
|
||||||
|
|
||||||
|
const x = this.preX + dx
|
||||||
|
const y = this.preY + dy
|
||||||
|
|
||||||
|
this.applyPosition(x, y, false)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:dragMove', {
|
||||||
|
detail: { x, y },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else if (this.resizeDir) {
|
||||||
|
this.performResize(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerUp = (e: PointerEvent) => {
|
||||||
|
if (this.dragging) {
|
||||||
|
this.dragUp(e)
|
||||||
|
}
|
||||||
|
if (this.resizeDir) {
|
||||||
|
this.resizeUp(e)
|
||||||
|
}
|
||||||
|
this.dragging = false
|
||||||
|
this.resizing = false
|
||||||
|
this.resizeDir = null
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
try {
|
||||||
|
this.releasePointerCapture?.(e.pointerId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有吸附点 */
|
||||||
|
private getSnapPoints() {
|
||||||
|
const snapPoints = { x: [] as number[], y: [] as number[] }
|
||||||
|
const containerRect = (this.dragContainer || document.body).getBoundingClientRect()
|
||||||
|
const rect = this.getBoundingClientRect()
|
||||||
|
snapPoints.x = [0, containerRect.width - rect.width]
|
||||||
|
snapPoints.y = [0, containerRect.height - rect.height]
|
||||||
|
return snapPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近的吸附点
|
||||||
|
* @param x 左上角起始点x
|
||||||
|
* @param y 左上角起始点y
|
||||||
|
*/
|
||||||
|
private applySnapping(x: number, y: number) {
|
||||||
|
let snappedX = x,
|
||||||
|
snappedY = y
|
||||||
|
const containerSnap = this.getSnapPoints()
|
||||||
|
if (this.snapDistance > 0) {
|
||||||
|
for (const sx of containerSnap.x)
|
||||||
|
if (Math.abs(x - sx) <= this.snapDistance) {
|
||||||
|
snappedX = sx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for (const sy of containerSnap.y)
|
||||||
|
if (Math.abs(y - sy) <= this.snapDistance) {
|
||||||
|
snappedY = sy
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: snappedX, y: snappedY }
|
||||||
|
}
|
||||||
|
|
||||||
|
private dragUp(e: PointerEvent) {
|
||||||
|
const snapped = this.applySnapping(this.x, this.y)
|
||||||
|
if (this.snapAnimation) {
|
||||||
|
this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => {
|
||||||
|
this.updateTargetBounds(snapped.x, snapped.y)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:dragEnd', {
|
||||||
|
detail: { x: snapped.x, y: snapped.y },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.applyPosition(snapped.x, snapped.y, true)
|
||||||
|
this.updateTargetBounds(snapped.x, snapped.y)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:dragEnd', {
|
||||||
|
detail: { x: snapped.x, y: snapped.y },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPosition(x: number, y: number, isFinal: boolean) {
|
||||||
|
this.x = x
|
||||||
|
this.y = y
|
||||||
|
this.style.transform = `translate(${x}px, ${y}px)`
|
||||||
|
if (isFinal) this.applyBoundary()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBoundary() {
|
||||||
|
if (this.allowOverflow) return
|
||||||
|
let { x, y } = { x: this.x, y: this.y }
|
||||||
|
|
||||||
|
const rect = this.getBoundingClientRect()
|
||||||
|
const containerRect = (this.dragContainer || document.body).getBoundingClientRect()
|
||||||
|
x = Math.min(Math.max(x, 0), containerRect.width - rect.width)
|
||||||
|
y = Math.min(Math.max(y, 0), containerRect.height - rect.height)
|
||||||
|
|
||||||
|
this.applyPosition(x, y, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateTo(targetX: number, targetY: number, duration: number, onComplete?: () => void) {
|
||||||
|
if (this.animationFrame) cancelAnimationFrame(this.animationFrame)
|
||||||
|
const startX = this.x
|
||||||
|
const startY = this.y
|
||||||
|
const deltaX = targetX - startX
|
||||||
|
const deltaY = targetY - startY
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
const step = (now: number) => {
|
||||||
|
const elapsed = now - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const ease = 1 - Math.pow(1 - progress, 3)
|
||||||
|
|
||||||
|
const x = startX + deltaX * ease
|
||||||
|
const y = startY + deltaY * ease
|
||||||
|
|
||||||
|
this.applyPosition(x, y, false)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:dragMove', {
|
||||||
|
detail: { x, y },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
this.animationFrame = requestAnimationFrame(step)
|
||||||
|
} else {
|
||||||
|
this.applyPosition(targetX, targetY, true)
|
||||||
|
onComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.animationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 缩放 ======
|
||||||
|
private startResize = (dir: TResizeDirection, e: PointerEvent) => {
|
||||||
|
if (!this.resizable) return
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.resizing = true
|
||||||
|
this.resizeDir = dir
|
||||||
|
this.startX = e.clientX
|
||||||
|
this.startY = e.clientY
|
||||||
|
|
||||||
|
const rect = this.getBoundingClientRect()
|
||||||
|
this.startWidth = rect.width
|
||||||
|
this.startHeight = rect.height
|
||||||
|
this.startX_host = rect.left
|
||||||
|
this.startY_host = rect.top
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
document.body.style.cursor = target.style.cursor || window.getComputedStyle(target).cursor
|
||||||
|
|
||||||
|
this.setPointerCapture?.(e.pointerId)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:resizeStart', {
|
||||||
|
detail: { x: this.x, y: this.y, width: this.width, height: this.height, dir },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private performResize(e: PointerEvent) {
|
||||||
|
if (!this.resizeDir || !this.resizing) return
|
||||||
|
|
||||||
|
let newWidth = this.startWidth
|
||||||
|
let newHeight = this.startHeight
|
||||||
|
let newX = this.startX_host
|
||||||
|
let newY = this.startY_host
|
||||||
|
|
||||||
|
const dx = e.clientX - this.startX
|
||||||
|
const dy = e.clientY - this.startY
|
||||||
|
|
||||||
|
switch (this.resizeDir) {
|
||||||
|
case 'r':
|
||||||
|
newWidth += dx
|
||||||
|
break
|
||||||
|
case 'b':
|
||||||
|
newHeight += dy
|
||||||
|
break
|
||||||
|
case 'l':
|
||||||
|
newWidth -= dx
|
||||||
|
newX += dx
|
||||||
|
break
|
||||||
|
case 't':
|
||||||
|
newHeight -= dy
|
||||||
|
newY += dy
|
||||||
|
break
|
||||||
|
case 'tl':
|
||||||
|
newWidth -= dx
|
||||||
|
newX += dx
|
||||||
|
newHeight -= dy
|
||||||
|
newY += dy
|
||||||
|
break
|
||||||
|
case 'tr':
|
||||||
|
newWidth += dx
|
||||||
|
newHeight -= dy
|
||||||
|
newY += dy
|
||||||
|
break
|
||||||
|
case 'bl':
|
||||||
|
newWidth -= dx
|
||||||
|
newX += dx
|
||||||
|
newHeight += dy
|
||||||
|
break
|
||||||
|
case 'br':
|
||||||
|
newWidth += dx
|
||||||
|
newHeight += dy
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = this.applyResizeBounds(newX, newY, newWidth, newHeight)
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:resizeMove', {
|
||||||
|
detail: { dir: this.resizeDir, width: d.width, height: d.height, left: d.left, top: d.top },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用尺寸调整边界
|
||||||
|
* @param newX 新的X坐标
|
||||||
|
* @param newY 新的Y坐标
|
||||||
|
* @param newWidth 新的宽度
|
||||||
|
* @param newHeight 新的高度
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private applyResizeBounds(
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
newWidth: number,
|
||||||
|
newHeight: number,
|
||||||
|
): {
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
} {
|
||||||
|
// 最小/最大宽高限制
|
||||||
|
if (this.minWidth != null) newWidth = Math.max(this.minWidth, newWidth)
|
||||||
|
if (this.maxWidth != null) newWidth = Math.min(this.maxWidth, newWidth)
|
||||||
|
if (this.minHeight != null) newHeight = Math.max(this.minHeight, newHeight)
|
||||||
|
if (this.maxHeight != null) newHeight = Math.min(this.maxHeight, newHeight)
|
||||||
|
|
||||||
|
// 边界限制
|
||||||
|
if (this.allowOverflow) {
|
||||||
|
this.x = newX
|
||||||
|
this.y = newY
|
||||||
|
this.width = newWidth
|
||||||
|
this.height = newHeight
|
||||||
|
this.style.width = `${newWidth}px`
|
||||||
|
this.style.height = `${newHeight}px`
|
||||||
|
this.applyPosition(newX, newY, false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: newX,
|
||||||
|
top: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = (this.dragContainer || document.body).getBoundingClientRect()
|
||||||
|
newX = Math.min(Math.max(0, newX), containerRect.width - newWidth)
|
||||||
|
newY = Math.min(Math.max(0, newY), containerRect.height - newHeight)
|
||||||
|
|
||||||
|
this.x = newX
|
||||||
|
this.y = newY
|
||||||
|
this.width = newWidth
|
||||||
|
this.height = newHeight
|
||||||
|
this.style.width = `${newWidth}px`
|
||||||
|
this.style.height = `${newHeight}px`
|
||||||
|
this.applyPosition(newX, newY, false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: newX,
|
||||||
|
top: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeUp(e: PointerEvent) {
|
||||||
|
if (!this.resizable) return
|
||||||
|
|
||||||
|
this.updateTargetBounds(this.x, this.y, this.width, this.height)
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:resizeEnd', {
|
||||||
|
detail: {
|
||||||
|
dir: this.resizeDir,
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
left: this.x,
|
||||||
|
top: this.y,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 窗口操作 ======
|
||||||
|
// 最小化到任务栏
|
||||||
|
private minimize() {
|
||||||
|
if (!this.taskbarElementId) return
|
||||||
|
if (this.windowFormState === 'minimized') return
|
||||||
|
this.targetPreMinimizeBounds = { ...this.targetBounds }
|
||||||
|
this.windowFormState = 'minimized'
|
||||||
|
|
||||||
|
const taskbar = document.querySelector(this.taskbarElementId)
|
||||||
|
if (!taskbar) throw new Error('任务栏元素未找到')
|
||||||
|
|
||||||
|
const rect = taskbar.getBoundingClientRect()
|
||||||
|
const startX = this.x
|
||||||
|
const startY = this.y
|
||||||
|
const startW = this.offsetWidth
|
||||||
|
const startH = this.offsetHeight
|
||||||
|
|
||||||
|
this.animateWindow(
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
startW,
|
||||||
|
startH,
|
||||||
|
rect.left,
|
||||||
|
rect.top,
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
400,
|
||||||
|
() => {
|
||||||
|
this.style.display = 'none'
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:stateChange:minimize', {
|
||||||
|
detail: { state: this.windowFormState },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最大化 */
|
||||||
|
private maximize() {
|
||||||
|
if (this.windowFormState === 'maximized') {
|
||||||
|
this.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.targetPreMaximizedBounds = { ...this.targetBounds }
|
||||||
|
this.windowFormState = 'maximized'
|
||||||
|
|
||||||
|
const rect = this.getBoundingClientRect()
|
||||||
|
|
||||||
|
const startX = this.x
|
||||||
|
const startY = this.y
|
||||||
|
const startW = rect.width
|
||||||
|
const startH = rect.height
|
||||||
|
|
||||||
|
const targetX = 0
|
||||||
|
const targetY = 0
|
||||||
|
const containerRect = (this.dragContainer || document.body).getBoundingClientRect()
|
||||||
|
const targetW = containerRect?.width ?? window.innerWidth
|
||||||
|
const targetH = containerRect?.height ?? window.innerHeight
|
||||||
|
|
||||||
|
this.animateWindow(
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
startW,
|
||||||
|
startH,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetW,
|
||||||
|
targetH,
|
||||||
|
300,
|
||||||
|
() => {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:stateChange:maximize', {
|
||||||
|
detail: { state: this.windowFormState },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复到默认窗体状态 */
|
||||||
|
private restore(onComplete?: () => void) {
|
||||||
|
if (this.windowFormState === 'default') return
|
||||||
|
let b: IElementRect
|
||||||
|
if (
|
||||||
|
(this.windowFormState as TWindowFormState) === 'minimized' &&
|
||||||
|
this.targetPreMinimizeBounds
|
||||||
|
) {
|
||||||
|
// 最小化恢复,恢复到最小化前的状态
|
||||||
|
b = this.targetPreMinimizeBounds
|
||||||
|
} else if (
|
||||||
|
(this.windowFormState as TWindowFormState) === 'maximized' &&
|
||||||
|
this.targetPreMaximizedBounds
|
||||||
|
) {
|
||||||
|
// 最大化恢复,恢复到最大化前的默认状态
|
||||||
|
b = this.targetPreMaximizedBounds
|
||||||
|
} else {
|
||||||
|
b = this.targetBounds
|
||||||
|
}
|
||||||
|
|
||||||
|
this.windowFormState = 'default'
|
||||||
|
|
||||||
|
this.style.display = 'block'
|
||||||
|
|
||||||
|
const startX = this.x
|
||||||
|
const startY = this.y
|
||||||
|
const startW = this.offsetWidth
|
||||||
|
const startH = this.offsetHeight
|
||||||
|
|
||||||
|
this.animateWindow(
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
startW,
|
||||||
|
startH,
|
||||||
|
b.left,
|
||||||
|
b.top,
|
||||||
|
b.width,
|
||||||
|
b.height,
|
||||||
|
300,
|
||||||
|
() => {
|
||||||
|
onComplete?.()
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:stateChange:restore', {
|
||||||
|
detail: { state: this.windowFormState },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private windowFormClose() {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:close', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
this.wfData.state.closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗体最大化、最小化和恢复默认 动画
|
||||||
|
* @param startX
|
||||||
|
* @param startY
|
||||||
|
* @param startW
|
||||||
|
* @param startH
|
||||||
|
* @param targetX
|
||||||
|
* @param targetY
|
||||||
|
* @param targetW
|
||||||
|
* @param targetH
|
||||||
|
* @param duration
|
||||||
|
* @param onComplete
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private animateWindow(
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
startW: number,
|
||||||
|
startH: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
targetW: number,
|
||||||
|
targetH: number,
|
||||||
|
duration: number,
|
||||||
|
onComplete?: () => void,
|
||||||
|
) {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const step = (now: number) => {
|
||||||
|
const elapsed = now - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const ease = 1 - Math.pow(1 - progress, 3)
|
||||||
|
|
||||||
|
const x = startX + (targetX - startX) * ease
|
||||||
|
const y = startY + (targetY - startY) * ease
|
||||||
|
const w = startW + (targetW - startW) * ease
|
||||||
|
const h = startH + (targetH - startH) * ease
|
||||||
|
|
||||||
|
this.style.width = `${w}px`
|
||||||
|
this.style.height = `${h}px`
|
||||||
|
this.applyPosition(x, y, false)
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(step)
|
||||||
|
} else {
|
||||||
|
this.style.width = `${targetW}px`
|
||||||
|
this.style.height = `${targetH}px`
|
||||||
|
this.applyPosition(targetX, targetY, true)
|
||||||
|
onComplete?.()
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('windowForm:stateChange', {
|
||||||
|
detail: { state: this.windowFormState },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTargetBounds(left: number, top: number, width?: number, height?: number) {
|
||||||
|
this.targetBounds = {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width: width ?? this.offsetWidth,
|
||||||
|
height: height ?? this.offsetHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 渲染 ======
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="window">
|
||||||
|
<div class="titlebar" @pointerdown=${this.onTitlePointerDown}>
|
||||||
|
<div class="title" title=${this.title}>${this.title}</div>
|
||||||
|
<div class="controls">
|
||||||
|
${this.minimizable
|
||||||
|
? html`<button
|
||||||
|
class="ctrl"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.minimize()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</button>`
|
||||||
|
: null}
|
||||||
|
${this.maximizable
|
||||||
|
? html`<button
|
||||||
|
class="ctrl"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.maximize()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${this.windowFormState === 'maximized' ? '▣' : '▢'}
|
||||||
|
</button>`
|
||||||
|
: null}
|
||||||
|
${this.closable
|
||||||
|
? html`<button
|
||||||
|
class="ctrl"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.windowFormClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content"><slot></slot></div>
|
||||||
|
|
||||||
|
${this.resizable
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="resizer t"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('t', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer b"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('b', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer r"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('r', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer l"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('l', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer tr"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('tr', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer tl"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('tl', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer br"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('br', e)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resizer bl"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.startResize('bl', e)}
|
||||||
|
></div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'window-form-element': WindowFormElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowFormElementEventMap extends WindowFormEventMap {}
|
||||||
|
}
|
||||||
101
src/core/window/ui/css/wf.scss
Normal file
101
src/core/window/ui/css/wf.scss
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box; /* 使用更直观的盒模型 */
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([focused]) {
|
||||||
|
z-index: 11;
|
||||||
|
.window {
|
||||||
|
border-color: #8338ec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([windowFormState='maximized']) {
|
||||||
|
.window {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
|
||||||
|
&.focus {
|
||||||
|
border-color: #3a86ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
height: var(--titlebar-height);
|
||||||
|
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;
|
||||||
|
|
||||||
|
&: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; }
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -8,16 +8,17 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"experimentalDecorators": true,
|
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
"lib": ["es2021", "dom"],
|
"lib": ["es2021", "dom"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"strict": true, // 严格模式检查
|
"strict": true, // 严格模式检查
|
||||||
|
"experimentalDecorators": true, // 装饰器
|
||||||
|
"useDefineForClassFields": false,
|
||||||
"strictPropertyInitialization": false, // 严格属性初始化检查
|
"strictPropertyInitialization": false, // 严格属性初始化检查
|
||||||
"noUnusedLocals": false, // 检查未使用的局部变量
|
"noUnusedLocals": false, // 检查未使用的局部变量
|
||||||
"noUnusedParameters": false, // 检查未使用的参数
|
"noUnusedParameters": false, // 检查未使用的参数
|
||||||
"noImplicitReturns": true, // 检查函数所有路径是否都有返回值
|
"noImplicitReturns": true, // 检查函数所有路径是否都有返回值
|
||||||
"noImplicitOverride": true, // 检查子类是否正确覆盖了父类方法
|
"noImplicitOverride": true, // 检查子类是否正确覆盖了父类方法
|
||||||
"allowSyntheticDefaultImports": true // 允许使用默认导入
|
"allowSyntheticDefaultImports": true, // 允许使用默认导入
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import UnoCSS from 'unocss/vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: tag => tag.endsWith('-element') // 忽略自定义元素
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
UnoCSS()
|
UnoCSS()
|
||||||
|
|||||||
Reference in New Issue
Block a user