Compare commits
24 Commits
8e05f068c5
...
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 | |||
| f83dd91dbd | |||
| c887853ec1 |
35
README.md
35
README.md
@@ -1,37 +1,2 @@
|
||||
# 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": {
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"lit": "^3.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^3.0.3",
|
||||
"uuid": "^11.1.0",
|
||||
|
||||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^13.6.0
|
||||
version: 13.6.0(vue@3.5.18(typescript@5.8.3))
|
||||
lit:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -413,6 +416,12 @@ packages:
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.4.0':
|
||||
resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==}
|
||||
|
||||
'@lit/reactive-element@2.1.1':
|
||||
resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -649,6 +658,9 @@ packages:
|
||||
'@types/node@22.17.1':
|
||||
resolution: {integrity: sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -1170,6 +1182,15 @@ packages:
|
||||
kolorist@1.8.0:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
lit-element@4.2.1:
|
||||
resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==}
|
||||
|
||||
lit-html@3.3.1:
|
||||
resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==}
|
||||
|
||||
lit@3.3.1:
|
||||
resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==}
|
||||
|
||||
local-pkg@1.1.1:
|
||||
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1920,6 +1941,12 @@ snapshots:
|
||||
|
||||
'@juggle/resize-observer@3.4.0': {}
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.4.0': {}
|
||||
|
||||
'@lit/reactive-element@2.1.1':
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.4.0
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
@@ -2071,6 +2098,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@unocss/astro@66.4.2(vite@7.1.1(@types/node@22.17.1)(jiti@2.5.1)(sass@1.90.0))':
|
||||
@@ -2679,6 +2708,22 @@ snapshots:
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
lit-element@4.2.1:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.4.0
|
||||
'@lit/reactive-element': 2.1.1
|
||||
lit-html: 3.3.1
|
||||
|
||||
lit-html@3.3.1:
|
||||
dependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
lit@3.3.1:
|
||||
dependencies:
|
||||
'@lit/reactive-element': 2.1.1
|
||||
lit-element: 4.2.1
|
||||
lit-html: 3.3.1
|
||||
|
||||
local-pkg@1.1.1:
|
||||
dependencies:
|
||||
mlly: 1.7.4
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import ProcessImpl from './process/impl/ProcessImpl.ts'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { BasicSystemProcess } from '@/core/system/BasicSystemProcess.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 { SettingsService } from '@/core/service/services/SettingsService.ts'
|
||||
import { WindowFormService } from '@/core/service/services/WindowFormService.ts'
|
||||
import { UserService } from '@/core/service/services/UserService.ts'
|
||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||
|
||||
interface IGlobalState {
|
||||
isLogin: boolean
|
||||
}
|
||||
|
||||
export default class XSystem {
|
||||
private static _instance: XSystem = new XSystem()
|
||||
|
||||
private _globalState: IObservable<IGlobalState> = new ObservableWeakRefImpl<IGlobalState>({
|
||||
isLogin: false
|
||||
})
|
||||
private _desktopRootDom: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
@@ -35,44 +22,13 @@ export default class XSystem {
|
||||
public static get instance() {
|
||||
return this._instance
|
||||
}
|
||||
public get globalState() {
|
||||
return this._globalState
|
||||
}
|
||||
public get desktopRootDom() {
|
||||
return this._desktopRootDom
|
||||
}
|
||||
|
||||
public initialization(dom: HTMLDivElement) {
|
||||
public async initialization(dom: HTMLDivElement) {
|
||||
this._desktopRootDom = dom
|
||||
this.run('basic-system', BasicSystemProcess).then(() => {
|
||||
this.run('desktop', DesktopProcess).then((proc) => {
|
||||
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
|
||||
await processManager.runProcess('basic-system', BasicSystemProcess)
|
||||
await processManager.runProcess('desktop', DesktopProcess, dom)
|
||||
}
|
||||
}
|
||||
|
||||
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 { debounce } from 'lodash'
|
||||
import type { IProcessInfo } from '@/core/process/IProcessInfo.ts'
|
||||
import { eventManager } from '@/core/events/EventManager.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 {
|
||||
private _desktopRootDom: HTMLElement;
|
||||
private _isMounted: boolean = false;
|
||||
private _width: number = 0;
|
||||
private _height: number = 0;
|
||||
private _pendingResize: boolean = false;
|
||||
/** 桌面根dom,类似显示器 */
|
||||
private readonly _monitorDom: HTMLElement
|
||||
private _isMounted: boolean = false
|
||||
private _data = new ObservableImpl<IDesktopDataState>({
|
||||
monitorWidth: 0,
|
||||
monitorHeight: 0,
|
||||
})
|
||||
|
||||
public get desktopRootDom() {
|
||||
return this._desktopRootDom;
|
||||
public get monitorDom() {
|
||||
return this._monitorDom
|
||||
}
|
||||
public get isMounted() {
|
||||
return this._isMounted;
|
||||
return this._isMounted
|
||||
}
|
||||
public get basicSystemProcess() {
|
||||
return processManager.findProcessByName<BasicSystemProcess>('basic-system')
|
||||
}
|
||||
|
||||
public get width() {
|
||||
return this._width;
|
||||
}
|
||||
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()
|
||||
get data() {
|
||||
return this._data
|
||||
}
|
||||
|
||||
private scheduleResizeEvent() {
|
||||
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) {
|
||||
constructor(info: IProcessInfo, dom: HTMLDivElement) {
|
||||
super(info)
|
||||
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.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 })
|
||||
app.use(naiveUi)
|
||||
app.mount(dom)
|
||||
|
||||
app.mount(this._monitorDom)
|
||||
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-bg">
|
||||
<div class="desktop-icons-container"
|
||||
:style="gridStyle">
|
||||
<AppIcon v-for="(appIcon, i) in appIconsRef" :key="i"
|
||||
:iconInfo="appIcon" :gridTemplate="gridTemplate"
|
||||
@dblclick="runApp(appIcon)"
|
||||
<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 id="taskbar" class="w-[80px] h-full flex justify-center items-center bg-blue">
|
||||
测试
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-config-provider>
|
||||
@@ -22,35 +26,46 @@
|
||||
|
||||
<script setup lang="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 { configProviderProps } from '@/core/common/naive-ui/theme.ts'
|
||||
import { useDesktopInit } from '@/core/desktop/ui/hooks/useDesktopInit.ts'
|
||||
import AppIcon from '@/core/desktop/ui/components/AppIcon.vue'
|
||||
import type { IDesktopAppIcon } from '@/core/desktop/types/IDesktopAppIcon.ts'
|
||||
import { eventManager } from '@/core/events/EventManager.ts'
|
||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||
|
||||
const props = defineProps<{ process: DesktopProcess }>()
|
||||
|
||||
props.process.data.subscribeKey(['monitorWidth', 'monitorHeight'], ({monitorWidth, monitorHeight}) => {
|
||||
console.log('onDesktopRootDomResize', monitorWidth, monitorHeight)
|
||||
notificationApi.create({
|
||||
title: '桌面通知',
|
||||
content: `桌面尺寸变化${monitorWidth}x${monitorHeight}}`,
|
||||
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,
|
||||
})
|
||||
},
|
||||
)
|
||||
// eventManager.addEventListener('onDesktopRootDomResize', (width, height) => {
|
||||
// console.log(width, height)
|
||||
// notificationApi.create({
|
||||
// title: '桌面通知',
|
||||
// content: `桌面尺寸变化${width}x${height}}`,
|
||||
// duration: 2000,
|
||||
// })
|
||||
// })
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const runApp = (appIcon: IDesktopAppIcon) => {
|
||||
XSystem.instance.run(appIcon.name)
|
||||
processManager.runProcess(appIcon.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -61,7 +76,7 @@ $taskBarHeight: 40px;
|
||||
|
||||
.desktop-bg {
|
||||
@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-size: cover;
|
||||
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 事件定义 键是事件名称,值是事件处理函数
|
||||
@@ -9,7 +11,7 @@ export interface IEventMap {
|
||||
/**
|
||||
* 事件管理器接口定义
|
||||
*/
|
||||
export interface IEventBuilder<Events extends IEventMap> {
|
||||
export interface IEventBuilder<Events extends IEventMap> extends IDestroyable {
|
||||
/**
|
||||
* 添加事件监听
|
||||
* @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
|
||||
}
|
||||
|
||||
export class EventBuilderImpl<Events extends IEventMap>
|
||||
implements IEventBuilder<Events>
|
||||
{
|
||||
export class EventBuilderImpl<Events extends IEventMap> implements IEventBuilder<Events> {
|
||||
private _eventHandlers = new Map<keyof Events, Set<HandlerWrapper<Events[keyof Events]>>>()
|
||||
|
||||
/**
|
||||
@@ -24,9 +22,9 @@ export class EventBuilderImpl<Events extends IEventMap>
|
||||
eventName: E,
|
||||
handler: F,
|
||||
options?: {
|
||||
immediate?: boolean;
|
||||
immediateArgs?: Parameters<F>;
|
||||
once?: boolean;
|
||||
immediate?: boolean
|
||||
immediateArgs?: Parameters<F>
|
||||
once?: boolean
|
||||
},
|
||||
) {
|
||||
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 { 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 */
|
||||
get id(): string;
|
||||
/** 进程信息 */
|
||||
get processInfo(): IProcessInfo;
|
||||
/** 进程的窗体列表 */
|
||||
get windowForms(): Map<string, IWindowForm>;
|
||||
get event(): IEventBuilder<IProcessEvent>;
|
||||
/**
|
||||
* 打开窗体
|
||||
* @param startName 窗体启动名
|
||||
*/
|
||||
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 { IWindowForm } from '@/core/window/IWindowForm.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 _windowForms: Map<string, IWindowForm> = new Map();
|
||||
private _event: IEventBuilder<IProcessEvent> = new EventBuilderImpl<IProcessEvent>()
|
||||
|
||||
public get id() {
|
||||
return this._id;
|
||||
@@ -23,6 +27,9 @@ export default class ProcessImpl implements IProcess {
|
||||
public get windowForms() {
|
||||
return this._windowForms;
|
||||
}
|
||||
public get event() {
|
||||
return this._event;
|
||||
}
|
||||
|
||||
constructor(info: IProcessInfo) {
|
||||
console.log(`AppProcess: ${info.name}`)
|
||||
@@ -30,6 +37,8 @@ export default class ProcessImpl implements IProcess {
|
||||
|
||||
const startName = info.startName;
|
||||
|
||||
this.initEvent();
|
||||
|
||||
processManager.registerProcess(this);
|
||||
// 通过设置 isJustProcess 为 true,则不会创建窗体
|
||||
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) {
|
||||
const info = this._processInfo.windowFormConfigs.find(item => item.name === startName);
|
||||
if (!info) throw new Error(`未找到窗体:${startName}`);
|
||||
const window = new WindowFormImpl(this, info);
|
||||
this._windowForms.set(window.id, window);
|
||||
const wf = new WindowFormImpl(this, info);
|
||||
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 { BasicSystemProcessInfo } from '@/core/system/BasicSystemProcessInfo.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 { IProcess } from '@/core/process/IProcess.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)
|
||||
}
|
||||
|
||||
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) {
|
||||
this._processPool.set(process.id, process);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IEventMap } from '@/core/events/IEventBuilder.ts'
|
||||
|
||||
/**
|
||||
* 进程的事件
|
||||
* <p>onProcessExit - 进程退出</p>
|
||||
@@ -6,9 +8,17 @@
|
||||
* <p>onProcessWindowFormFocus - 进程的窗体获取焦点</p>
|
||||
*
|
||||
*/
|
||||
type TAppProcessEvent =
|
||||
type TProcessEvent =
|
||||
'onProcessExit' |
|
||||
'onProcessWindowFormOpen' |
|
||||
'onProcessWindowFormExit' |
|
||||
'onProcessWindowFormFocus' |
|
||||
'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 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 {
|
||||
id: string;
|
||||
@@ -13,67 +17,47 @@ interface IWindow {
|
||||
}
|
||||
|
||||
export class WindowFormService extends AService {
|
||||
private windows: Map<string, IWindow> = new Map();
|
||||
private zCounter = 1;
|
||||
private windows: Map<string, IWindowForm> = new Map();
|
||||
|
||||
constructor() {
|
||||
super("WindowForm");
|
||||
super("WindowFormService");
|
||||
console.log('WindowFormService - 服务注册')
|
||||
}
|
||||
|
||||
createWindow(title: string, config?: Partial<IWindow>): IWindow {
|
||||
const id = `win-${Date.now()}-${Math.random()}`;
|
||||
const win: IWindow = {
|
||||
id,
|
||||
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;
|
||||
public createWindow(proc: IProcess, info: IWindowFormConfig): IWindowForm {
|
||||
const window = new WindowFormImpl(proc, info);
|
||||
this.windows.set(window.id, window);
|
||||
return window;
|
||||
}
|
||||
|
||||
closeWindow(id: string) {
|
||||
public closeWindow(id: string) {
|
||||
if (this.windows.has(id)) {
|
||||
this.windows.delete(id);
|
||||
this.sm.broadcast("WindowFrom:closed", id);
|
||||
}
|
||||
}
|
||||
|
||||
focusWindow(id: string) {
|
||||
public focusWindow(id: string) {
|
||||
const win = this.windows.get(id);
|
||||
if (win) {
|
||||
win.zIndex = this.zCounter++;
|
||||
this.sm.broadcast("WindowFrom:focused", win);
|
||||
}
|
||||
}
|
||||
|
||||
minimizeWindow(id: string) {
|
||||
public minimizeWindow(id: string) {
|
||||
const win = this.windows.get(id);
|
||||
if (win) {
|
||||
win.minimized = true;
|
||||
this.sm.broadcast("WindowFrom:minimized", win);
|
||||
}
|
||||
}
|
||||
|
||||
maximizeWindow(id: string) {
|
||||
public maximizeWindow(id: string) {
|
||||
const win = this.windows.get(id);
|
||||
if (win) {
|
||||
win.maximized = !win.maximized;
|
||||
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) {
|
||||
console.log(`[WindowService] 收到事件:`, event, data);
|
||||
}
|
||||
|
||||
@@ -10,68 +10,69 @@ import type {
|
||||
* 创建一个可观察对象,用于管理状态和事件。
|
||||
* @template T - 需要处理的状态类型
|
||||
* @example
|
||||
* interface Todos {
|
||||
* id: number
|
||||
* text: string
|
||||
* done: boolean
|
||||
* }
|
||||
*
|
||||
* interface AppState {
|
||||
* count: number
|
||||
* todos: Todos[]
|
||||
* user: {
|
||||
* name: string
|
||||
* age: number
|
||||
* }
|
||||
* items: number[]
|
||||
* inc(): void
|
||||
* }
|
||||
*
|
||||
* // 创建 ObservableImpl
|
||||
* const obs = new ObservableImpl<AppState>({
|
||||
* count: 0,
|
||||
* user: { name: 'Alice', age: 20 },
|
||||
* items: []
|
||||
* todos: [],
|
||||
* user: { name: "Alice", age: 20 },
|
||||
* inc() {
|
||||
* this.count++ // ✅ this 指向 obs.state
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* // 1️⃣ 全量订阅
|
||||
* const unsubscribeAll = obs.subscribe(state => {
|
||||
* console.log('全量订阅', state)
|
||||
* }, { immediate: true })
|
||||
* // ================== 使用示例 ==================
|
||||
*
|
||||
* // 2️⃣ 单字段订阅
|
||||
* const unsubscribeCount = obs.subscribeKey('count', ({ count }) => {
|
||||
* console.log('count 字段变化:', count)
|
||||
* // 1. 订阅整个 state
|
||||
* obs.subscribe(state => {
|
||||
* console.log("[全量订阅] state 更新:", state)
|
||||
* })
|
||||
*
|
||||
* // 3️⃣ 多字段订阅
|
||||
* const unsubscribeUser = obs.subscribeKey(['user', 'count'], ({ user, count }) => {
|
||||
* console.log('user 或 count 变化:', { user, count })
|
||||
* // 2. 订阅单个字段
|
||||
* obs.subscribeKey("count", ({ count }) => {
|
||||
* console.log("[字段订阅] count 更新:", count)
|
||||
* })
|
||||
*
|
||||
* // 4️⃣ 修改属性
|
||||
* obs.state.count = 1 // ✅ 会触发 count 和全量订阅
|
||||
* obs.state.user.age = 21 // ✅ 深层对象修改触发 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 }
|
||||
* // 3. 订阅多个字段
|
||||
* obs.subscribeKey(["name", "age"] as (keyof AppState["user"])[], (user) => {
|
||||
* console.log("[多字段订阅] user 更新:", user)
|
||||
* })
|
||||
*
|
||||
* // 7️⃣ 解构赋值访问对象属性仍然触发订阅
|
||||
* const { state } = obs
|
||||
* state.user.age = 31 // ✅ 会触发 user 订阅
|
||||
* // 4. 批量更新
|
||||
* obs.patch({ count: 10, user: { name: "Bob", age: 30 } })
|
||||
*
|
||||
* // 8️⃣ 取消订阅
|
||||
* unsubscribeAll()
|
||||
* unsubscribeCount()
|
||||
* unsubscribeUser()
|
||||
* // 5. 方法里操作 state
|
||||
* obs.state.inc() // this.count++ → 相当于 obs.state.count++
|
||||
*
|
||||
* // 9️⃣ 销毁 ObservableImpl
|
||||
* obs.dispose()
|
||||
* // 6. 数组操作
|
||||
* 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> {
|
||||
/** Observable 状态对象,深层 Proxy */
|
||||
@@ -80,8 +81,13 @@ export class ObservableImpl<T extends TNonFunctionProperties<T>> implements IObs
|
||||
/** 全量订阅函数集合 */
|
||||
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()
|
||||
@@ -92,166 +98,208 @@ export class ObservableImpl<T extends TNonFunctionProperties<T>> implements IObs
|
||||
/** 是否已销毁 */
|
||||
private disposed = false
|
||||
|
||||
/** 缓存 Proxy,避免重复包装 */
|
||||
private proxyCache: WeakMap<object, TObservableState<unknown>> = new WeakMap()
|
||||
|
||||
constructor(initialState: TNonFunctionProperties<T>) {
|
||||
// 创建深层响应式 Proxy
|
||||
this.state = this.makeReactive(initialState) as TObservableState<T>
|
||||
}
|
||||
|
||||
/** 创建深层 Proxy,拦截 get/set */
|
||||
private makeReactive(obj: TNonFunctionProperties<T>): TObservableState<T> {
|
||||
const handler: ProxyHandler<any> = {
|
||||
get: (target, prop: string | symbol, receiver) => {
|
||||
const key = prop as keyof T
|
||||
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,拦截 get/set/delete,并自动缓存 */
|
||||
private makeReactive<O extends object>(obj: O): TObservableState<O> {
|
||||
// 非对象直接返回(包括 null 已被排除)
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return obj as unknown as TObservableState<O>
|
||||
}
|
||||
|
||||
// 如果已有 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
|
||||
},
|
||||
set: (target, prop: string | symbol, value, receiver) => {
|
||||
const key = prop as keyof T
|
||||
const oldValue = target[key]
|
||||
if (oldValue !== value) {
|
||||
target[key] = value
|
||||
this.pendingKeys.add(key)
|
||||
|
||||
set: (target, prop, value, receiver) => {
|
||||
// 读取旧值(使用 Record 以便类型安全访问属性)
|
||||
const oldValue = (target as Record<PropertyKey, unknown>)[prop as PropertyKey] as unknown
|
||||
const result = Reflect.set(target, prop, value as unknown, receiver)
|
||||
// 仅在值改变时触发通知(基于引用/原始值比较)
|
||||
if (!this.disposed && oldValue !== (value as unknown)) {
|
||||
this.pendingKeys.add(prop as keyof T)
|
||||
this.scheduleNotify()
|
||||
}
|
||||
return true
|
||||
return result
|
||||
},
|
||||
|
||||
deleteProperty: (target, prop) => {
|
||||
if (prop in target) {
|
||||
// 使用 Reflect.deleteProperty 以保持一致性
|
||||
const deleted = Reflect.deleteProperty(target, prop)
|
||||
if (deleted && !this.disposed) {
|
||||
this.pendingKeys.add(prop as keyof T)
|
||||
this.scheduleNotify()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return new Proxy(obj, handler) as TObservableState<T>
|
||||
|
||||
const proxy = new Proxy(obj, handler) as TObservableState<O>
|
||||
this.proxyCache.set(obj as object, proxy as TObservableState<unknown>)
|
||||
return proxy
|
||||
}
|
||||
|
||||
/** 包装数组方法,使 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
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
/** 安排下一次通知 */
|
||||
/** 安排下一次通知(微任务合并) */
|
||||
private scheduleNotify(): void {
|
||||
if (!this.notifyScheduled && !this.disposed) {
|
||||
if (!this.notifyScheduled && !this.disposed && this.pendingKeys.size > 0) {
|
||||
this.notifyScheduled = true
|
||||
Promise.resolve().then(() => this.flushNotify())
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行通知 */
|
||||
/** 执行通知(聚合字段订阅并保证错误隔离) */
|
||||
private flushNotify(): void {
|
||||
if (this.disposed) return
|
||||
const keys = Array.from(this.pendingKeys)
|
||||
|
||||
this.pendingKeys.clear()
|
||||
this.notifyScheduled = false
|
||||
|
||||
// 全量订阅
|
||||
// 全量订阅 —— 每个订阅单独 try/catch,避免一个错误阻塞其它订阅
|
||||
for (const fn of this.listeners) {
|
||||
fn(this.state)
|
||||
}
|
||||
|
||||
// 字段订阅
|
||||
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)
|
||||
try {
|
||||
fn(this.state as unknown as T)
|
||||
} catch (err) {
|
||||
console.error("Observable listener error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
fnMap.forEach((subKeys, fn) => {
|
||||
const result = {} as Pick<T, typeof subKeys[number]>
|
||||
subKeys.forEach(k => (result[k] = this.state[k]))
|
||||
fn(result)
|
||||
// ================== 字段订阅 ==================
|
||||
// 遍历所有回调,每个回调都返回它订阅的字段(即使只有部分字段变化)
|
||||
this.keyListeners.forEach((subKeys, fn) => {
|
||||
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)
|
||||
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 () => {
|
||||
this.listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
/** 订阅指定字段变化 */
|
||||
subscribeKey<K extends keyof T>(
|
||||
/** 订阅指定字段变化(多字段订阅 always 返回所有字段值) */
|
||||
public subscribeKey<K extends keyof T>(
|
||||
keys: K | K[],
|
||||
fn: TObservableKeyListener<T, K>,
|
||||
options: { immediate?: boolean } = {}
|
||||
): () => void {
|
||||
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) {
|
||||
const result = {} as Pick<T, K>
|
||||
keyArray.forEach(k => (result[k] = this.state[k]))
|
||||
fn(result)
|
||||
keyArray.forEach(k => {
|
||||
result[k] = (this.state as Record<keyof T, unknown>)[k] as T[K]
|
||||
})
|
||||
try {
|
||||
fn(result)
|
||||
} catch (err) {
|
||||
console.error("Observable subscribeKey immediate error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ================== 返回取消订阅函数 ==================
|
||||
return () => {
|
||||
for (const key of keyArray) {
|
||||
this.keyListeners.get(key)?.delete(fn)
|
||||
}
|
||||
this.keyListeners.delete(fn as TObservableKeyListener<T, keyof T>)
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量更新状态 */
|
||||
patch(values: Partial<T>): void {
|
||||
/** 批量更新状态(避免重复 schedule) */
|
||||
public patch(values: Partial<T>): void {
|
||||
let changed = false
|
||||
for (const key in values) {
|
||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||
const typedKey = key as keyof T
|
||||
this.state[typedKey] = values[typedKey]!
|
||||
this.pendingKeys.add(typedKey)
|
||||
const oldValue = (this.state as Record<keyof T, unknown>)[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 实例 */
|
||||
dispose(): void {
|
||||
public dispose(): void {
|
||||
this.disposed = true
|
||||
this.listeners.clear()
|
||||
this.keyListeners.clear()
|
||||
this.pendingKeys.clear()
|
||||
this.proxyCache = new WeakMap()
|
||||
Object.freeze(this.state)
|
||||
}
|
||||
|
||||
/** 语法糖:返回一个可解构赋值的 Proxy */
|
||||
toRefsProxy(): { [K in keyof T]: T[K] } {
|
||||
public toRefsProxy(): { [K in keyof T]: T[K] } {
|
||||
const self = this
|
||||
return new Proxy({} as T, {
|
||||
return new Proxy({} as { [K in keyof T]: T[K] }, {
|
||||
get(_, prop: string | symbol) {
|
||||
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) {
|
||||
const key = prop as keyof T
|
||||
self.state[key] = value
|
||||
;(self.state as Record<keyof T, unknown>)[key] = value as unknown
|
||||
return true
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(self.state)
|
||||
},
|
||||
getOwnPropertyDescriptor(_, prop: string | symbol) {
|
||||
getOwnPropertyDescriptor(_, _prop: string | symbol) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,14 @@
|
||||
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 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 { IWindowForm } from '@/core/window/IWindowForm.ts'
|
||||
import type { IWindowFormConfig } from '@/core/window/types/IWindowFormConfig.ts'
|
||||
import type { WindowFormPos } from '@/core/window/types/WindowFormTypes.ts'
|
||||
import { processManager } from '@/core/process/ProcessManager.ts'
|
||||
import { DraggableResizable } from '@/core/utils/DraggableResizable.ts'
|
||||
import type { TWindowFormState, WindowFormPos } from '@/core/window/types/WindowFormTypes.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 {
|
||||
private readonly _id: string = uuidV4();
|
||||
private readonly _procId: string;
|
||||
private pos: WindowFormPos = { x: 0, y: 0 };
|
||||
private width: number = 0;
|
||||
private height: number = 0;
|
||||
private readonly _id: string = uuidV4()
|
||||
private readonly _proc: IProcess
|
||||
private readonly _data: IObservable<IWindowFormDataState>
|
||||
private dom: HTMLElement
|
||||
private drw: DraggableResizableWindow
|
||||
|
||||
public get id() {
|
||||
return this._id;
|
||||
return this._id
|
||||
}
|
||||
public get proc() {
|
||||
return processManager.findProcessById(this._procId)
|
||||
return this._proc
|
||||
}
|
||||
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) {
|
||||
this._procId = proc.id;
|
||||
this._proc = proc
|
||||
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,
|
||||
y: config.top ?? 0
|
||||
}
|
||||
this.width = config.width ?? 0;
|
||||
this.height = config.height ?? 0;
|
||||
y: config.top ?? 0,
|
||||
width: config.width ?? 200,
|
||||
height: config.height ?? 100,
|
||||
state: 'default',
|
||||
closed: false,
|
||||
})
|
||||
|
||||
this.createWindowFrom();
|
||||
this.initEvent()
|
||||
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();
|
||||
private initEvent() {
|
||||
this._data.subscribeKey('closed', (state) => {
|
||||
console.log('closed', state)
|
||||
this.closeWindowForm()
|
||||
this._proc.closeWindowForm(this.id)
|
||||
})
|
||||
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,
|
||||
mode: 'position',
|
||||
snapThreshold: 20,
|
||||
boundary: document.body,
|
||||
taskbarElementId: '#taskbar',
|
||||
})
|
||||
|
||||
this.desktopRootDom.appendChild(dom);
|
||||
}
|
||||
|
||||
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;
|
||||
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": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"experimentalDecorators": true,
|
||||
"target": "es2021",
|
||||
"lib": ["es2021", "dom"],
|
||||
"module": "ESNext",
|
||||
"strict": true, // 严格模式检查
|
||||
"experimentalDecorators": true, // 装饰器
|
||||
"useDefineForClassFields": false,
|
||||
"strictPropertyInitialization": false, // 严格属性初始化检查
|
||||
"noUnusedLocals": false, // 检查未使用的局部变量
|
||||
"noUnusedParameters": false, // 检查未使用的参数
|
||||
"noImplicitReturns": true, // 检查函数所有路径是否都有返回值
|
||||
"noImplicitOverride": true, // 检查子类是否正确覆盖了父类方法
|
||||
"allowSyntheticDefaultImports": true // 允许使用默认导入
|
||||
"allowSyntheticDefaultImports": true, // 允许使用默认导入
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ import UnoCSS from 'unocss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.endsWith('-element') // 忽略自定义元素
|
||||
}
|
||||
}
|
||||
}),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
UnoCSS()
|
||||
|
||||
Reference in New Issue
Block a user