@owdproject/react-theme-win95 0.0.0-stage → 4.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -2
- package/package.json +54 -4
- package/src/DesktopShell.tsx +84 -0
- package/src/applications.ts +25 -0
- package/src/components/DesktopWindow.tsx +134 -0
- package/src/components/StartMenuPanel.tsx +32 -0
- package/src/components/SystemBar.tsx +73 -0
- package/src/desktop-context.ts +18 -0
- package/src/index.ts +17 -0
- package/src/theme.css +221 -0
- package/src/types.ts +22 -0
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @owdproject/react-theme-win95
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Windows 95 theme for **Open Web Desktop**, built with **React** and
|
|
4
|
+
[React95](https://react95.io).
|
|
5
|
+
|
|
6
|
+
- **Engine**: [`@owdproject/core`](https://github.com/owdproject/core) — headless windows/apps/workspaces.
|
|
7
|
+
- **Bridges**: [`@owdproject/react`](https://github.com/owdproject/core) — `useSyncExternalStore` hooks.
|
|
8
|
+
- **UI**: [`@owdproject/react-kit-react95`](../owd-kit-react95) — [React95](https://react95.io) `@react95/core` integration.
|
|
9
|
+
|
|
10
|
+
This is a **minimal vertical slice**: a working desktop shell (background, draggable/resizable
|
|
11
|
+
React95 windows, task bar, start menu) plus two demo apps. It proves the whole React chain works
|
|
12
|
+
end-to-end on top of the OWD engine.
|
|
13
|
+
|
|
14
|
+
## Run it
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# from the repo root (client/)
|
|
18
|
+
pnpm install
|
|
19
|
+
pnpm --filter @owdproject/react-theme-win95 dev
|
|
20
|
+
# open http://localhost:5173
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Use it
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { createDesktop } from '@owdproject/core'
|
|
27
|
+
import { DesktopShell } from '@owdproject/react-theme-win95'
|
|
28
|
+
import '@owdproject/react-kit-react95/styles.css'
|
|
29
|
+
import '@owdproject/react-theme-win95/styles.css'
|
|
30
|
+
|
|
31
|
+
const desktop = createDesktop()
|
|
32
|
+
|
|
33
|
+
export default function App() {
|
|
34
|
+
return <DesktopShell desktop={desktop} watermark="React win95" />
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Custom apps
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import type { DesktopApp } from '@owdproject/react-theme-win95'
|
|
42
|
+
import { Notepad } from '@react95/icons'
|
|
43
|
+
|
|
44
|
+
const apps: DesktopApp[] = [
|
|
45
|
+
{
|
|
46
|
+
id: 'hello',
|
|
47
|
+
title: 'Hello',
|
|
48
|
+
icon: <Notepad variant="16x16_4" />,
|
|
49
|
+
defaultSize: { width: 420, height: 300 },
|
|
50
|
+
component: ({ window, desktop }) => (
|
|
51
|
+
<div style={{ padding: 8 }}>
|
|
52
|
+
<p>Window {window.id}</p>
|
|
53
|
+
<button onClick={() => desktop.windows.closeWindow(window.id)}>Close</button>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Architecture
|
|
61
|
+
|
|
62
|
+
| File | Role |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `src/DesktopShell.tsx` | Root shell: workarea, background, windows, start menu, task bar. |
|
|
65
|
+
| `src/components/DesktopWindow.tsx` | One window. Reads state from `useWindowInstance`, moves/resizes via `useDesktopDrag` / `useDesktopResize`. Commits geometry back to the core on release. |
|
|
66
|
+
| `src/components/SystemBar.tsx` | Task bar (React95 `Frame` + `Button`), driven by `useWindowManager`. |
|
|
67
|
+
| `src/components/StartMenuPanel.tsx` | Start menu (React95 `List`). |
|
|
68
|
+
| `src/apps/*` | Demo apps (`About`, `Notepad`). |
|
|
69
|
+
| `src/theme.css` | Layout + app styles (visual primitives come from the kit). |
|
|
70
|
+
| `playground/` | Vite + React playground app. |
|
|
71
|
+
|
|
72
|
+
`@owdproject/core` is the single source of truth for window geometry and focus; React95 only
|
|
73
|
+
renders the chrome, and `@owdproject/react` bridges the immutable core snapshots to React.
|
|
74
|
+
|
|
75
|
+
> ℹ️ The playground Vite config includes a `build.rollupOptions.treeshake.moduleSideEffects`
|
|
76
|
+
> rule for `@react95/*` (they ship no `sideEffects: false`), otherwise all 1000+ React95 icons
|
|
77
|
+
> get bundled. Copy it into your own app's Vite config.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owdproject/react-theme-win95",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "4.0.0-alpha.4",
|
|
4
|
+
"description": "Windows 95 theme for Open Web Desktop built with React, @owdproject/core and @owdproject/react.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Diego Villa",
|
|
8
|
+
"url": "https://github.com/dxlliv"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"module": "./src/index.ts",
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"import": "./src/index.ts",
|
|
18
|
+
"default": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"./styles.css": "./src/theme.css",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "vite --config playground/vite.config.ts",
|
|
28
|
+
"build": "vite build --config playground/vite.config.ts",
|
|
29
|
+
"preview": "vite preview --config playground/vite.config.ts",
|
|
30
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@owdproject/core": "^4.0.0-alpha.3",
|
|
34
|
+
"@owdproject/react-kit-react95": "^4.0.0-alpha.3",
|
|
35
|
+
"@owdproject/react": "^4.0.0-alpha.3",
|
|
36
|
+
"@react95/icons": "^2.8.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": ">=18.0.0",
|
|
40
|
+
"react-dom": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/react": "^19.0.0",
|
|
44
|
+
"@types/react-dom": "^19.0.0",
|
|
45
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
46
|
+
"react": "^19.0.0",
|
|
47
|
+
"react-dom": "^19.0.0",
|
|
48
|
+
"typescript": "~6.0.3",
|
|
49
|
+
"vite": "^6.0.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"tag": "alpha",
|
|
54
|
+
"registry": "https://registry.npmjs.org/"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { Desktop } from '@owdproject/core'
|
|
3
|
+
import { useWindowManager } from '@owdproject/react'
|
|
4
|
+
import { React95Provider } from '@owdproject/react-kit-react95'
|
|
5
|
+
import { DesktopRuntimeContext } from './desktop-context'
|
|
6
|
+
import { DesktopWindow } from './components/DesktopWindow'
|
|
7
|
+
import { SystemBar } from './components/SystemBar'
|
|
8
|
+
import { StartMenuPanel } from './components/StartMenuPanel'
|
|
9
|
+
import { registerDesktopApps } from './applications'
|
|
10
|
+
import type { DesktopApp } from './types'
|
|
11
|
+
|
|
12
|
+
const SYSTEMBAR_HEIGHT = 28
|
|
13
|
+
const EMPTY_APPS: DesktopApp[] = []
|
|
14
|
+
|
|
15
|
+
export interface DesktopShellProps {
|
|
16
|
+
/** A `@owdproject/core` Desktop instance. */
|
|
17
|
+
desktop: Desktop
|
|
18
|
+
/** Menu entries; components/configs register with the engine on mount. */
|
|
19
|
+
apps?: DesktopApp[]
|
|
20
|
+
/** Centered desktop watermark text. */
|
|
21
|
+
watermark?: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Root Windows 95 desktop shell for React.
|
|
26
|
+
* Composes `@owdproject/react-kit-react95` chrome with `@owdproject/react` hooks
|
|
27
|
+
* and the headless `@owdproject/core` engine.
|
|
28
|
+
*/
|
|
29
|
+
export function DesktopShell({ desktop, apps = EMPTY_APPS, watermark = 'Open Web Desktop' }: DesktopShellProps) {
|
|
30
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
31
|
+
const [startOpen, setStartOpen] = useState(false)
|
|
32
|
+
const { windows } = useWindowManager(desktop.windows)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
registerDesktopApps(desktop, apps)
|
|
36
|
+
}, [desktop, apps])
|
|
37
|
+
|
|
38
|
+
const getWorkarea = useCallback(() => {
|
|
39
|
+
const el = rootRef.current
|
|
40
|
+
const width = el?.clientWidth ?? (typeof window !== 'undefined' ? window.innerWidth : 1024)
|
|
41
|
+
const height = (el?.clientHeight ?? (typeof window !== 'undefined' ? window.innerHeight : 768)) - SYSTEMBAR_HEIGHT
|
|
42
|
+
return { x: 0, y: 0, width, height }
|
|
43
|
+
}, [])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const sync = () => desktop.workspaces.setWorkarea(getWorkarea())
|
|
47
|
+
sync()
|
|
48
|
+
window.addEventListener('resize', sync)
|
|
49
|
+
return () => window.removeEventListener('resize', sync)
|
|
50
|
+
}, [desktop, getWorkarea])
|
|
51
|
+
|
|
52
|
+
const runtime = useMemo(() => ({ desktop, getWorkarea }), [desktop, getWorkarea])
|
|
53
|
+
|
|
54
|
+
const launch = (app: DesktopApp) => {
|
|
55
|
+
void desktop.applications.launch(app.id, app.entryKey, app.argument)
|
|
56
|
+
setStartOpen(false)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const activeWindows = windows.filter((win) => win.state.active !== false)
|
|
60
|
+
const appById = new Map(apps.map((app) => [app.id, app]))
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<DesktopRuntimeContext.Provider value={runtime}>
|
|
64
|
+
<React95Provider>
|
|
65
|
+
<div ref={rootRef} className="owd-shell" onPointerDown={() => setStartOpen(false)}>
|
|
66
|
+
<div className="owd-shell__desktop r95-owd-desktop">
|
|
67
|
+
{watermark ? <div className="owd-shell__watermark">{watermark}</div> : null}
|
|
68
|
+
{activeWindows.map((win) => (
|
|
69
|
+
<DesktopWindow key={win.id} window={win} app={appById.get(win.applicationId ?? '')} />
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{startOpen ? (
|
|
74
|
+
<div className="owd-shell__startmenu" onPointerDown={(event) => event.stopPropagation()}>
|
|
75
|
+
<StartMenuPanel apps={apps} onLaunch={launch} />
|
|
76
|
+
</div>
|
|
77
|
+
) : null}
|
|
78
|
+
|
|
79
|
+
<SystemBar apps={apps} startActive={startOpen} onStartClick={() => setStartOpen((open) => !open)} />
|
|
80
|
+
</div>
|
|
81
|
+
</React95Provider>
|
|
82
|
+
</DesktopRuntimeContext.Provider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Desktop } from '@owdproject/core'
|
|
2
|
+
import { registerReactApplication } from '@owdproject/react'
|
|
3
|
+
import type { DesktopApp } from './types'
|
|
4
|
+
|
|
5
|
+
/** Register shorthand apps once. Existing registrations remain owned by the consumer. */
|
|
6
|
+
export function registerDesktopApps(desktop: Desktop, apps: readonly DesktopApp[]): void {
|
|
7
|
+
for (const app of apps) {
|
|
8
|
+
if (desktop.applications.get(app.id) || (!app.component && !app.config)) continue
|
|
9
|
+
const { entries = {}, ...config } = app.config ?? {}
|
|
10
|
+
registerReactApplication(desktop, {
|
|
11
|
+
...config,
|
|
12
|
+
id: app.id,
|
|
13
|
+
title: app.title,
|
|
14
|
+
entries,
|
|
15
|
+
windows: config.windows ?? (app.component ? {
|
|
16
|
+
main: {
|
|
17
|
+
title: app.title,
|
|
18
|
+
size: app.defaultSize ?? { width: 480, height: 340 },
|
|
19
|
+
position: { x: 48, y: 40 },
|
|
20
|
+
component: app.component,
|
|
21
|
+
},
|
|
22
|
+
} : undefined),
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useReducer, useRef, type CSSProperties } from 'react'
|
|
2
|
+
import type { ResizeDirection, WindowBounds, WindowInstance, WindowState } from '@owdproject/core'
|
|
3
|
+
import { closeReactApplicationWindow, ReactApplicationWindow, useDesktopDrag, useDesktopResize, useWindowInstance } from '@owdproject/react'
|
|
4
|
+
import { OwdWindow } from '@owdproject/react-kit-react95'
|
|
5
|
+
import { useDesktopRuntime } from '../desktop-context'
|
|
6
|
+
import type { DesktopApp } from '../types'
|
|
7
|
+
|
|
8
|
+
const FALLBACK_SIZE = { width: 480, height: 340 }
|
|
9
|
+
|
|
10
|
+
function readBounds(state: WindowState, fallback: { width: number; height: number }): WindowBounds {
|
|
11
|
+
const pos = state.position ?? {}
|
|
12
|
+
const size = state.size ?? {}
|
|
13
|
+
return {
|
|
14
|
+
x: pos.x ?? 0,
|
|
15
|
+
y: pos.y ?? 0,
|
|
16
|
+
width: typeof size.width === 'number' ? size.width : fallback.width,
|
|
17
|
+
height: typeof size.height === 'number' ? size.height : fallback.height,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DesktopWindowProps {
|
|
22
|
+
window: WindowInstance
|
|
23
|
+
app?: DesktopApp
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A single desktop window: reads live state from `@owdproject/react`'s
|
|
28
|
+
* `useWindowInstance`, drives move/resize through `@owdproject/core`'s pointer
|
|
29
|
+
* sessions (`useDesktopDrag` / `useDesktopResize`) and renders React95 window
|
|
30
|
+
* chrome via the kit's `OwdWindow`.
|
|
31
|
+
*
|
|
32
|
+
* While dragging, geometry is kept in local state for smoothness and committed
|
|
33
|
+
* back to the core engine on release.
|
|
34
|
+
*/
|
|
35
|
+
export function DesktopWindow({ window: instance, app }: DesktopWindowProps) {
|
|
36
|
+
const { desktop, getWorkarea } = useDesktopRuntime()
|
|
37
|
+
const { state } = useWindowInstance(instance)
|
|
38
|
+
const fallback = app?.defaultSize ?? FALLBACK_SIZE
|
|
39
|
+
|
|
40
|
+
const liveBounds = useRef<WindowBounds | null>(null)
|
|
41
|
+
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
|
42
|
+
|
|
43
|
+
const commit = (bounds: WindowBounds) => {
|
|
44
|
+
liveBounds.current = null
|
|
45
|
+
instance.setPosition({ x: bounds.x, y: bounds.y })
|
|
46
|
+
instance.setSize({ width: bounds.width, height: bounds.height })
|
|
47
|
+
forceRender()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { startDrag } = useDesktopDrag({
|
|
51
|
+
getBounds: () => readBounds(instance.getSnapshot(), fallback),
|
|
52
|
+
getWorkarea,
|
|
53
|
+
onMove: (bounds) => {
|
|
54
|
+
liveBounds.current = bounds
|
|
55
|
+
forceRender()
|
|
56
|
+
},
|
|
57
|
+
onEnd: commit,
|
|
58
|
+
onCancel: () => {
|
|
59
|
+
liveBounds.current = null
|
|
60
|
+
forceRender()
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const { startResize } = useDesktopResize({
|
|
65
|
+
getBounds: () => readBounds(instance.getSnapshot(), fallback),
|
|
66
|
+
minWidth: 180,
|
|
67
|
+
minHeight: 120,
|
|
68
|
+
onMove: (bounds) => {
|
|
69
|
+
liveBounds.current = bounds
|
|
70
|
+
forceRender()
|
|
71
|
+
},
|
|
72
|
+
onEnd: commit,
|
|
73
|
+
onCancel: () => {
|
|
74
|
+
liveBounds.current = null
|
|
75
|
+
forceRender()
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const bounds = liveBounds.current ?? readBounds(state, fallback)
|
|
80
|
+
const maximized = state.maximized === true
|
|
81
|
+
|
|
82
|
+
const style: CSSProperties = {
|
|
83
|
+
left: bounds.x,
|
|
84
|
+
top: bounds.y,
|
|
85
|
+
width: bounds.width,
|
|
86
|
+
height: bounds.height,
|
|
87
|
+
zIndex: state.position?.z ?? 0,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const focus = () => desktop.windows.focusWindow(instance.id)
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<OwdWindow
|
|
94
|
+
title={instance.config.title ?? app?.title ?? instance.id}
|
|
95
|
+
icon={app?.icon}
|
|
96
|
+
active={state.focused}
|
|
97
|
+
maximized={maximized}
|
|
98
|
+
resizable={state.resizable !== false}
|
|
99
|
+
minimizable={state.minimizable !== false}
|
|
100
|
+
maximizable={state.maximizable !== false}
|
|
101
|
+
closable={state.destroyable !== false}
|
|
102
|
+
style={style}
|
|
103
|
+
onPointerDownCapture={focus}
|
|
104
|
+
onTitlePointerDown={(event) => {
|
|
105
|
+
if (event.button !== 0) return
|
|
106
|
+
// Capturing pointers from title-bar controls prevents their click action.
|
|
107
|
+
if (event.target instanceof Element && event.target.closest('button, [role="button"]')) return
|
|
108
|
+
focus()
|
|
109
|
+
startDrag(event.nativeEvent)
|
|
110
|
+
}}
|
|
111
|
+
onTitleDoubleClick={() => {
|
|
112
|
+
if (state.maximizable === false) return
|
|
113
|
+
instance.setLayout(maximized ? 'normal' : 'maximize', getWorkarea())
|
|
114
|
+
}}
|
|
115
|
+
onResizePointerDown={(event, direction) => {
|
|
116
|
+
if (event.button !== 0) return
|
|
117
|
+
focus()
|
|
118
|
+
startResize(event.nativeEvent, direction as ResizeDirection)
|
|
119
|
+
}}
|
|
120
|
+
onClose={() => { void closeReactApplicationWindow(desktop, instance) }}
|
|
121
|
+
onMinimize={() => instance.minimize()}
|
|
122
|
+
onToggleMaximize={() => {
|
|
123
|
+
if (state.maximizable === false) return
|
|
124
|
+
instance.setLayout(maximized ? 'normal' : 'maximize', getWorkarea())
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{instance.config.component ? (
|
|
128
|
+
<ReactApplicationWindow window={instance} desktop={desktop} />
|
|
129
|
+
) : (
|
|
130
|
+
<div className="owd-window__empty">No component registered for “{instance.model}”.</div>
|
|
131
|
+
)}
|
|
132
|
+
</OwdWindow>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PowerOff } from '@react95/icons'
|
|
2
|
+
import { List } from '@owdproject/react-kit-react95'
|
|
3
|
+
import type { DesktopApp } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface StartMenuPanelProps {
|
|
6
|
+
apps: DesktopApp[]
|
|
7
|
+
onLaunch: (app: DesktopApp) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Start menu built with React95 `List` + `List.Item`. */
|
|
11
|
+
export function StartMenuPanel({ apps, onLaunch }: StartMenuPanelProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="owd-startmenu">
|
|
14
|
+
<div className="owd-startmenu__rail">
|
|
15
|
+
<span>
|
|
16
|
+
Open Web <strong>Desktop</strong>
|
|
17
|
+
</span>
|
|
18
|
+
</div>
|
|
19
|
+
<List className="owd-startmenu__list">
|
|
20
|
+
{apps.map((app) => (
|
|
21
|
+
<List.Item key={app.id} icon={app.icon} onClick={() => onLaunch(app)}>
|
|
22
|
+
{app.title}
|
|
23
|
+
</List.Item>
|
|
24
|
+
))}
|
|
25
|
+
<List.Divider />
|
|
26
|
+
<List.Item icon={<PowerOff variant="16x16_4" />} disabled>
|
|
27
|
+
Shut Down…
|
|
28
|
+
</List.Item>
|
|
29
|
+
</List>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Logo } from '@react95/icons'
|
|
3
|
+
import { Button, Frame } from '@owdproject/react-kit-react95'
|
|
4
|
+
import { useWindowManager } from '@owdproject/react'
|
|
5
|
+
import { useDesktopRuntime } from '../desktop-context'
|
|
6
|
+
import type { DesktopApp } from '../types'
|
|
7
|
+
|
|
8
|
+
function useClock() {
|
|
9
|
+
const [now, setNow] = useState(() => new Date())
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const id = setInterval(() => setNow(new Date()), 10_000)
|
|
12
|
+
return () => clearInterval(id)
|
|
13
|
+
}, [])
|
|
14
|
+
return now
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SystemBarProps {
|
|
18
|
+
apps: DesktopApp[]
|
|
19
|
+
onStartClick: () => void
|
|
20
|
+
startActive: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bottom system bar built from React95 `Frame` + `Button` primitives.
|
|
25
|
+
* Unlike React95's own `TaskBar`, it is driven by the OWD window manager
|
|
26
|
+
* (`useWindowManager`), so it lists real `@owdproject/core` windows.
|
|
27
|
+
*/
|
|
28
|
+
export function SystemBar({ apps, onStartClick, startActive }: SystemBarProps) {
|
|
29
|
+
const { desktop } = useDesktopRuntime()
|
|
30
|
+
const { windows, focused } = useWindowManager(desktop.windows)
|
|
31
|
+
const now = useClock()
|
|
32
|
+
|
|
33
|
+
const appById = new Map(apps.map((app) => [app.id, app]))
|
|
34
|
+
const clock = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Frame className="owd-taskbar">
|
|
38
|
+
<Button
|
|
39
|
+
className={`owd-taskbar__start${startActive ? ' is-active' : ''}`}
|
|
40
|
+
onClick={onStartClick}
|
|
41
|
+
aria-pressed={startActive}
|
|
42
|
+
>
|
|
43
|
+
<Logo variant="16x16_4" />
|
|
44
|
+
<span className="owd-taskbar__start-label">Start</span>
|
|
45
|
+
</Button>
|
|
46
|
+
|
|
47
|
+
<Frame className="owd-taskbar__tasks">
|
|
48
|
+
{windows.map((win) => {
|
|
49
|
+
const app = appById.get(win.applicationId ?? '')
|
|
50
|
+
const title = win.config.title ?? app?.title ?? win.id
|
|
51
|
+
const isActive = focused?.id === win.id
|
|
52
|
+
return (
|
|
53
|
+
<Button
|
|
54
|
+
key={win.id}
|
|
55
|
+
className={`owd-task${isActive ? ' is-active' : ''}`}
|
|
56
|
+
title={title}
|
|
57
|
+
onClick={() => {
|
|
58
|
+
if (win.state.active === false) win.unminimize()
|
|
59
|
+
desktop.windows.focusWindow(win.id)
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<span className="owd-task__label">{title}</span>
|
|
63
|
+
</Button>
|
|
64
|
+
)
|
|
65
|
+
})}
|
|
66
|
+
</Frame>
|
|
67
|
+
|
|
68
|
+
<Frame className="owd-taskbar__tray">
|
|
69
|
+
<span className="owd-taskbar__clock">{clock}</span>
|
|
70
|
+
</Frame>
|
|
71
|
+
</Frame>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { Desktop, DesktopWorkareaRect } from '@owdproject/core'
|
|
3
|
+
|
|
4
|
+
export interface DesktopRuntime {
|
|
5
|
+
desktop: Desktop
|
|
6
|
+
/** Current usable desktop area (excluding the system bar), in shell coordinates. */
|
|
7
|
+
getWorkarea: () => DesktopWorkareaRect
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DesktopRuntimeContext = createContext<DesktopRuntime | null>(null)
|
|
11
|
+
|
|
12
|
+
export function useDesktopRuntime(): DesktopRuntime {
|
|
13
|
+
const runtime = useContext(DesktopRuntimeContext)
|
|
14
|
+
if (!runtime) {
|
|
15
|
+
throw new Error('[owd-react-theme-win95] useDesktopRuntime() must be used inside <DesktopShell>.')
|
|
16
|
+
}
|
|
17
|
+
return runtime
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @owdproject/react-theme-win95
|
|
3
|
+
* Windows 95 Open Web Desktop theme for React.
|
|
4
|
+
*
|
|
5
|
+
* Built on the headless `@owdproject/core` engine, the `@owdproject/react`
|
|
6
|
+
* hooks and the `@owdproject/react-kit-react95` visual primitives.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { DesktopShell, type DesktopShellProps } from './DesktopShell'
|
|
10
|
+
export { DesktopWindow, type DesktopWindowProps } from './components/DesktopWindow'
|
|
11
|
+
export { SystemBar, type SystemBarProps } from './components/SystemBar'
|
|
12
|
+
export { StartMenuPanel, type StartMenuPanelProps } from './components/StartMenuPanel'
|
|
13
|
+
export { useDesktopRuntime, DesktopRuntimeContext, type DesktopRuntime } from './desktop-context'
|
|
14
|
+
export type { DesktopApp, DesktopAppRenderProps } from './types'
|
|
15
|
+
|
|
16
|
+
// Re-export the kit primitives so consumers get one entry point.
|
|
17
|
+
export * from '@owdproject/react-kit-react95'
|
package/src/theme.css
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @owdproject/react-theme-win95 — theme layout & app styles.
|
|
3
|
+
* Visual primitives come from @react95/core (via @owdproject/react-kit-react95).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* The theme owns its kit: loading the theme styles also loads the kit's OWD
|
|
7
|
+
window glue, so consumers only import this single stylesheet. */
|
|
8
|
+
@import '@owdproject/react-kit-react95/styles.css';
|
|
9
|
+
|
|
10
|
+
.owd-shell {
|
|
11
|
+
position: fixed;
|
|
12
|
+
inset: 0;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.owd-shell__desktop {
|
|
17
|
+
position: absolute;
|
|
18
|
+
inset: 0 0 28px 0;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.owd-shell__watermark {
|
|
23
|
+
position: absolute;
|
|
24
|
+
right: 16px;
|
|
25
|
+
bottom: 16px;
|
|
26
|
+
color: rgba(255, 255, 255, 0.6);
|
|
27
|
+
font-weight: bold;
|
|
28
|
+
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.35);
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.owd-shell__startmenu {
|
|
33
|
+
position: absolute;
|
|
34
|
+
left: 2px;
|
|
35
|
+
bottom: 28px;
|
|
36
|
+
z-index: 100000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ----------------------------------------------------------- System bar */
|
|
40
|
+
|
|
41
|
+
.owd-taskbar {
|
|
42
|
+
position: fixed;
|
|
43
|
+
left: 0;
|
|
44
|
+
right: 0;
|
|
45
|
+
bottom: 0;
|
|
46
|
+
height: 28px;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 4px;
|
|
50
|
+
padding: 2px;
|
|
51
|
+
z-index: 90000;
|
|
52
|
+
background: var(--r95-color-material, #c3c7cb);
|
|
53
|
+
box-shadow:
|
|
54
|
+
inset 1px 1px 0 var(--r95-color-borderLightest, #fff),
|
|
55
|
+
inset -1px -1px 0 var(--r95-color-borderDark, #868a8e);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.owd-taskbar__start {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 4px;
|
|
62
|
+
font-weight: bold;
|
|
63
|
+
flex: 0 0 auto;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.owd-taskbar__start.is-active {
|
|
67
|
+
padding-top: 2px;
|
|
68
|
+
padding-left: 2px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.owd-taskbar__start-label {
|
|
72
|
+
line-height: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.owd-taskbar__tasks {
|
|
76
|
+
flex: 1 1 auto;
|
|
77
|
+
min-width: 0;
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 3px;
|
|
81
|
+
background: transparent;
|
|
82
|
+
box-shadow: none;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.owd-task {
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
min-width: 110px;
|
|
90
|
+
max-width: 168px;
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.owd-task.is-active {
|
|
95
|
+
box-shadow:
|
|
96
|
+
inset 1px 1px 0 var(--r95-color-borderDark, #868a8e),
|
|
97
|
+
inset -1px -1px 0 var(--r95-color-borderLightest, #fff);
|
|
98
|
+
background-color: var(--r95-color-borderLight, #d2d2d2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.owd-task__label {
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
text-overflow: ellipsis;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.owd-taskbar__tray {
|
|
108
|
+
flex: 0 0 auto;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
padding: 0 6px;
|
|
112
|
+
background: transparent;
|
|
113
|
+
box-shadow: none;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.owd-taskbar__clock {
|
|
117
|
+
padding: 0 2px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------ Start menu */
|
|
121
|
+
|
|
122
|
+
.owd-startmenu {
|
|
123
|
+
display: flex;
|
|
124
|
+
background: var(--r95-color-material, #c3c7cb);
|
|
125
|
+
border: 2px solid;
|
|
126
|
+
border-color: var(--r95-color-borderLight) var(--r95-color-borderDarkest)
|
|
127
|
+
var(--r95-color-borderDarkest) var(--r95-color-borderLight);
|
|
128
|
+
box-shadow: inset 1px 1px 0 var(--r95-color-borderLightest);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.owd-startmenu__rail {
|
|
132
|
+
width: 26px;
|
|
133
|
+
background: linear-gradient(180deg, #000e7a, #1084d0);
|
|
134
|
+
color: #fff;
|
|
135
|
+
writing-mode: vertical-rl;
|
|
136
|
+
transform: rotate(180deg);
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: flex-end;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
padding: 8px 0;
|
|
141
|
+
font-weight: bold;
|
|
142
|
+
letter-spacing: 1px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.owd-startmenu__list {
|
|
146
|
+
border: 0;
|
|
147
|
+
box-shadow: none;
|
|
148
|
+
background: transparent;
|
|
149
|
+
min-width: 190px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ------------------------------------------------------- App: About box */
|
|
153
|
+
|
|
154
|
+
.owd-about {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
gap: 8px;
|
|
158
|
+
height: 100%;
|
|
159
|
+
padding: 4px;
|
|
160
|
+
user-select: text;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.owd-about__banner {
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 10px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.owd-about__small {
|
|
170
|
+
color: #404040;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.owd-about__divider {
|
|
174
|
+
width: 100%;
|
|
175
|
+
border: 0;
|
|
176
|
+
border-top: 1px solid var(--r95-color-borderDark);
|
|
177
|
+
border-bottom: 1px solid var(--r95-color-borderLight);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.owd-about__text {
|
|
181
|
+
margin: 0;
|
|
182
|
+
line-height: 1.5;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.owd-about__actions {
|
|
186
|
+
margin-top: auto;
|
|
187
|
+
display: flex;
|
|
188
|
+
justify-content: flex-end;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* --------------------------------------------------------- App: Notepad */
|
|
192
|
+
|
|
193
|
+
.owd-notepad {
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-direction: column;
|
|
196
|
+
height: 100%;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.owd-notepad__menu {
|
|
200
|
+
display: flex;
|
|
201
|
+
gap: 14px;
|
|
202
|
+
padding: 2px 6px;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.owd-notepad__area {
|
|
206
|
+
flex: 1 1 auto;
|
|
207
|
+
width: 100%;
|
|
208
|
+
resize: none;
|
|
209
|
+
border: 0;
|
|
210
|
+
outline: none;
|
|
211
|
+
padding: 4px;
|
|
212
|
+
background: #fff;
|
|
213
|
+
font-family: 'Lucida Console', 'Courier New', monospace;
|
|
214
|
+
font-size: 12px;
|
|
215
|
+
line-height: 1.4;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.owd-window__empty {
|
|
219
|
+
padding: 8px;
|
|
220
|
+
color: #404040;
|
|
221
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from 'react'
|
|
2
|
+
import type { ReactApplicationConfig, ReactApplicationWindowProps } from '@owdproject/react'
|
|
3
|
+
|
|
4
|
+
/** Props passed to every application window component. */
|
|
5
|
+
export type DesktopAppRenderProps = ReactApplicationWindowProps
|
|
6
|
+
|
|
7
|
+
/** A React application that can be launched from the start menu / task bar. */
|
|
8
|
+
export interface DesktopApp {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
/** React95 icon used in the start menu, window title bar and task bar. */
|
|
12
|
+
icon?: ReactElement
|
|
13
|
+
defaultSize?: { width: number; height: number }
|
|
14
|
+
/** Optional shorthand for a single `main` window. Registered apps need no component here. */
|
|
15
|
+
component?: ComponentType<DesktopAppRenderProps>
|
|
16
|
+
/** Engine options, including entries, models, singleton and lifecycle callbacks. */
|
|
17
|
+
config?: Omit<ReactApplicationConfig, 'id' | 'title' | 'entries'> & {
|
|
18
|
+
entries?: ReactApplicationConfig['entries']
|
|
19
|
+
}
|
|
20
|
+
entryKey?: string
|
|
21
|
+
argument?: string
|
|
22
|
+
}
|