@backbay/glia-desktop 0.2.0-alpha.1
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/package.json +37 -0
- package/src/components/GliaErrorBoundary/GliaErrorBoundary.tsx +202 -0
- package/src/components/GliaErrorBoundary/index.ts +2 -0
- package/src/components/GliaErrorBoundary/useErrorBoundary.tsx +61 -0
- package/src/components/desktop/Desktop.tsx +204 -0
- package/src/components/desktop/DesktopIcon.tsx +293 -0
- package/src/components/desktop/FileBrowser.stories.tsx +287 -0
- package/src/components/desktop/FileBrowser.tsx +981 -0
- package/src/components/desktop/SnapZoneOverlay.tsx +230 -0
- package/src/components/desktop/index.ts +15 -0
- package/src/components/index.ts +16 -0
- package/src/components/shell/Clock.tsx +212 -0
- package/src/components/shell/ContextMenu.tsx +249 -0
- package/src/components/shell/GlassMenubar.stories.tsx +382 -0
- package/src/components/shell/GlassMenubar.tsx +632 -0
- package/src/components/shell/NotificationCenter.stories.tsx +515 -0
- package/src/components/shell/NotificationCenter.tsx +545 -0
- package/src/components/shell/NotificationToast.tsx +319 -0
- package/src/components/shell/StartMenu.stories.tsx +249 -0
- package/src/components/shell/StartMenu.tsx +568 -0
- package/src/components/shell/SystemTray.stories.tsx +492 -0
- package/src/components/shell/SystemTray.tsx +457 -0
- package/src/components/shell/Taskbar.tsx +387 -0
- package/src/components/shell/TaskbarButton.tsx +208 -0
- package/src/components/shell/index.ts +37 -0
- package/src/components/window/Window.tsx +751 -0
- package/src/components/window/WindowTitlebar.tsx +359 -0
- package/src/components/window/index.ts +10 -0
- package/src/core/desktop/fileBrowserTypes.ts +112 -0
- package/src/core/desktop/index.ts +8 -0
- package/src/core/desktop/types.ts +185 -0
- package/src/core/desktop/useFileBrowser.tsx +405 -0
- package/src/core/desktop/useSnapZones.tsx +203 -0
- package/src/core/index.ts +11 -0
- package/src/core/shell/__tests__/useNotifications.test.ts +155 -0
- package/src/core/shell/__tests__/useTaskbar.test.ts +99 -0
- package/src/core/shell/index.ts +10 -0
- package/src/core/shell/notificationTypes.ts +110 -0
- package/src/core/shell/types.ts +194 -0
- package/src/core/shell/useNotifications.tsx +259 -0
- package/src/core/shell/useStartMenu.tsx +242 -0
- package/src/core/shell/useSystemTray.tsx +175 -0
- package/src/core/shell/useTaskbar.tsx +320 -0
- package/src/core/useKeyboardNavigation.ts +41 -0
- package/src/core/window/__tests__/useWindowManager.test.ts +269 -0
- package/src/core/window/index.ts +6 -0
- package/src/core/window/types.ts +149 -0
- package/src/core/window/useWindowManager.tsx +1154 -0
- package/src/index.ts +146 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/DesktopOSProvider.tsx +391 -0
- package/src/providers/ThemeProvider.tsx +162 -0
- package/src/providers/index.ts +6 -0
- package/src/themes/default.ts +107 -0
- package/src/themes/index.ts +6 -0
- package/src/themes/types.ts +230 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @backbay/glia Desktop OS - useWindowManager Hook
|
|
3
|
+
*
|
|
4
|
+
* Core window management hook using Zustand.
|
|
5
|
+
* Provides all window lifecycle, focus, and state management operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { create, createStore, useStore, type StoreApi } from 'zustand';
|
|
9
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
10
|
+
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
11
|
+
import type {
|
|
12
|
+
WindowId,
|
|
13
|
+
WindowState,
|
|
14
|
+
WindowGroup,
|
|
15
|
+
WindowOpenConfig,
|
|
16
|
+
UseWindowManagerReturn,
|
|
17
|
+
TilePosition,
|
|
18
|
+
} from './types';
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
// Constants
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TASKBAR_HEIGHT = 48;
|
|
25
|
+
const CASCADE_OFFSET = 32;
|
|
26
|
+
const MAX_CASCADE_STEPS = 8;
|
|
27
|
+
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
// Helper Functions
|
|
30
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a unique window ID using crypto.randomUUID() for SSR safety.
|
|
34
|
+
* Falls back to timestamp-based generation if crypto is unavailable.
|
|
35
|
+
*/
|
|
36
|
+
function generateWindowId(): WindowId {
|
|
37
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
38
|
+
return `window-${crypto.randomUUID()}`;
|
|
39
|
+
}
|
|
40
|
+
// Fallback for environments without crypto.randomUUID
|
|
41
|
+
return `window-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate a unique group ID using crypto.randomUUID() for SSR safety.
|
|
46
|
+
* Falls back to timestamp-based generation if crypto is unavailable.
|
|
47
|
+
*/
|
|
48
|
+
function generateGroupId(): string {
|
|
49
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
50
|
+
return `group-${crypto.randomUUID()}`;
|
|
51
|
+
}
|
|
52
|
+
// Fallback for environments without crypto.randomUUID
|
|
53
|
+
return `group-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getViewportSize() {
|
|
57
|
+
if (typeof globalThis.innerWidth === 'undefined') {
|
|
58
|
+
return { width: 1920, height: 1080 };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
width: globalThis.innerWidth,
|
|
62
|
+
height: globalThis.innerHeight - DEFAULT_TASKBAR_HEIGHT,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getTileDimensions(position: TilePosition) {
|
|
67
|
+
const { width: screenWidth, height: screenHeight } = getViewportSize();
|
|
68
|
+
const halfWidth = screenWidth / 2;
|
|
69
|
+
const halfHeight = screenHeight / 2;
|
|
70
|
+
|
|
71
|
+
switch (position) {
|
|
72
|
+
case 'left':
|
|
73
|
+
return { x: 0, y: 0, width: halfWidth, height: screenHeight };
|
|
74
|
+
case 'right':
|
|
75
|
+
return { x: halfWidth, y: 0, width: halfWidth, height: screenHeight };
|
|
76
|
+
case 'top-left':
|
|
77
|
+
return { x: 0, y: 0, width: halfWidth, height: halfHeight };
|
|
78
|
+
case 'top-right':
|
|
79
|
+
return { x: halfWidth, y: 0, width: halfWidth, height: halfHeight };
|
|
80
|
+
case 'bottom-left':
|
|
81
|
+
return { x: 0, y: halfHeight, width: halfWidth, height: halfHeight };
|
|
82
|
+
case 'bottom-right':
|
|
83
|
+
return { x: halfWidth, y: halfHeight, width: halfWidth, height: halfHeight };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function calculateInitialPosition(
|
|
88
|
+
config: WindowOpenConfig,
|
|
89
|
+
windowCount: number
|
|
90
|
+
): { x: number; y: number } {
|
|
91
|
+
if (config.position) {
|
|
92
|
+
return config.position;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const viewport = getViewportSize();
|
|
96
|
+
const width = config.size?.width ?? 640;
|
|
97
|
+
const height = config.size?.height ?? 480;
|
|
98
|
+
const offset = (windowCount % MAX_CASCADE_STEPS) * CASCADE_OFFSET;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
x: Math.max(60, (viewport.width - width) / 2 + offset),
|
|
102
|
+
y: Math.max(60, (viewport.height - height) / 2 + offset),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
107
|
+
// Store Interface
|
|
108
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
interface WindowManagerStore {
|
|
111
|
+
windows: Map<WindowId, WindowState>;
|
|
112
|
+
groups: Map<string, WindowGroup>;
|
|
113
|
+
focusedId: WindowId | null;
|
|
114
|
+
fullscreenId: WindowId | null;
|
|
115
|
+
nextZIndex: number;
|
|
116
|
+
|
|
117
|
+
// Internal actions (not exposed in hook return)
|
|
118
|
+
_open: (config: WindowOpenConfig) => WindowId;
|
|
119
|
+
_close: (id: WindowId) => void;
|
|
120
|
+
_focus: (id: WindowId) => void;
|
|
121
|
+
_minimize: (id: WindowId) => void;
|
|
122
|
+
_maximize: (id: WindowId) => void;
|
|
123
|
+
_restore: (id: WindowId) => void;
|
|
124
|
+
_fullscreen: (id: WindowId) => void;
|
|
125
|
+
_exitFullscreen: () => void;
|
|
126
|
+
_move: (id: WindowId, position: { x: number; y: number }) => void;
|
|
127
|
+
_resize: (id: WindowId, size: { width: number; height: number }) => void;
|
|
128
|
+
_tile: (id: WindowId, position: TilePosition) => void;
|
|
129
|
+
_untile: (id: WindowId) => void;
|
|
130
|
+
_cycleFocusNext: () => void;
|
|
131
|
+
_createGroup: (windowId1: WindowId, windowId2: WindowId) => string | null;
|
|
132
|
+
_addToGroup: (groupId: string, windowId: WindowId) => void;
|
|
133
|
+
_removeFromGroup: (windowId: WindowId) => void;
|
|
134
|
+
_setActiveTab: (groupId: string, windowId: WindowId) => void;
|
|
135
|
+
_minimizeAll: () => void;
|
|
136
|
+
_closeAll: () => void;
|
|
137
|
+
_cascade: () => void;
|
|
138
|
+
_tileAll: (layout: 'horizontal' | 'vertical' | 'grid') => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
142
|
+
// Store Factory
|
|
143
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
function createWindowManagerStoreImpl(
|
|
146
|
+
set: StoreApi<WindowManagerStore>['setState'],
|
|
147
|
+
get: StoreApi<WindowManagerStore>['getState'],
|
|
148
|
+
): WindowManagerStore {
|
|
149
|
+
return {
|
|
150
|
+
windows: new Map(),
|
|
151
|
+
groups: new Map(),
|
|
152
|
+
focusedId: null,
|
|
153
|
+
fullscreenId: null,
|
|
154
|
+
nextZIndex: 1,
|
|
155
|
+
|
|
156
|
+
_open: (config) => {
|
|
157
|
+
const id = generateWindowId();
|
|
158
|
+
|
|
159
|
+
set((state) => {
|
|
160
|
+
const position = calculateInitialPosition(config, state.windows.size);
|
|
161
|
+
|
|
162
|
+
const newWindow: WindowState = {
|
|
163
|
+
id,
|
|
164
|
+
title: config.title,
|
|
165
|
+
icon: config.icon,
|
|
166
|
+
position,
|
|
167
|
+
size: config.size ?? { width: 640, height: 480 },
|
|
168
|
+
minSize: config.minSize ?? { width: 320, height: 240 },
|
|
169
|
+
maxSize: config.maxSize,
|
|
170
|
+
isMinimized: config.isMinimized ?? false,
|
|
171
|
+
isMaximized: config.isMaximized ?? false,
|
|
172
|
+
isFullscreen: false,
|
|
173
|
+
isFocused: true,
|
|
174
|
+
zIndex: state.nextZIndex,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const newWindows = new Map(state.windows);
|
|
178
|
+
newWindows.set(id, newWindow);
|
|
179
|
+
return {
|
|
180
|
+
windows: newWindows,
|
|
181
|
+
focusedId: id,
|
|
182
|
+
nextZIndex: state.nextZIndex + 1,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return id;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
_close: (id) => {
|
|
190
|
+
const { windows, groups, fullscreenId } = get();
|
|
191
|
+
const window = windows.get(id);
|
|
192
|
+
if (!window) return;
|
|
193
|
+
|
|
194
|
+
const wasFullscreen = fullscreenId === id || window.isFullscreen;
|
|
195
|
+
|
|
196
|
+
// Handle browser fullscreen exit
|
|
197
|
+
if (wasFullscreen && typeof document !== 'undefined') {
|
|
198
|
+
document.exitFullscreen?.().catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Handle group cleanup
|
|
202
|
+
if (window.groupId) {
|
|
203
|
+
const group = groups.get(window.groupId);
|
|
204
|
+
if (group && group.windowIds.length <= 2) {
|
|
205
|
+
const remainingWindowId = group.windowIds.find((wId) => wId !== id);
|
|
206
|
+
if (remainingWindowId) {
|
|
207
|
+
get()._removeFromGroup(remainingWindowId);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
get()._removeFromGroup(id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
set((state) => {
|
|
215
|
+
const newWindows = new Map(state.windows);
|
|
216
|
+
newWindows.delete(id);
|
|
217
|
+
|
|
218
|
+
// Focus topmost remaining window
|
|
219
|
+
let newFocusedId = state.focusedId;
|
|
220
|
+
if (state.focusedId === id) {
|
|
221
|
+
const remaining = Array.from(newWindows.values());
|
|
222
|
+
if (remaining.length > 0) {
|
|
223
|
+
newFocusedId = remaining.reduce((a, b) =>
|
|
224
|
+
a.zIndex > b.zIndex ? a : b
|
|
225
|
+
).id;
|
|
226
|
+
} else {
|
|
227
|
+
newFocusedId = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
windows: newWindows,
|
|
233
|
+
focusedId: newFocusedId,
|
|
234
|
+
fullscreenId: wasFullscreen ? null : state.fullscreenId,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
_focus: (id) => {
|
|
240
|
+
const { windows, focusedId, groups } = get();
|
|
241
|
+
const window = windows.get(id);
|
|
242
|
+
|
|
243
|
+
if (!window || id === focusedId) return;
|
|
244
|
+
|
|
245
|
+
// Restore if minimized
|
|
246
|
+
if (window.isMinimized) {
|
|
247
|
+
get()._restore(id);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// If in a group, also set as active tab
|
|
252
|
+
if (window.groupId) {
|
|
253
|
+
const group = groups.get(window.groupId);
|
|
254
|
+
if (group && group.activeWindowId !== id) {
|
|
255
|
+
get()._setActiveTab(window.groupId, id);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
set((state) => {
|
|
261
|
+
const newWindows = new Map(state.windows);
|
|
262
|
+
const w = newWindows.get(id);
|
|
263
|
+
if (w) {
|
|
264
|
+
newWindows.set(id, { ...w, zIndex: state.nextZIndex, isFocused: true });
|
|
265
|
+
}
|
|
266
|
+
// Unfocus previous window
|
|
267
|
+
if (state.focusedId && state.focusedId !== id) {
|
|
268
|
+
const prev = newWindows.get(state.focusedId);
|
|
269
|
+
if (prev) {
|
|
270
|
+
newWindows.set(state.focusedId, { ...prev, isFocused: false });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
windows: newWindows,
|
|
275
|
+
focusedId: id,
|
|
276
|
+
nextZIndex: state.nextZIndex + 1,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
_minimize: (id) => {
|
|
282
|
+
const { windows, groups } = get();
|
|
283
|
+
const window = windows.get(id);
|
|
284
|
+
if (!window) return;
|
|
285
|
+
|
|
286
|
+
// If in a group, minimize all windows in the group
|
|
287
|
+
const windowsToMinimize = window.groupId
|
|
288
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
289
|
+
: [id];
|
|
290
|
+
|
|
291
|
+
set((state) => {
|
|
292
|
+
const newWindows = new Map(state.windows);
|
|
293
|
+
for (const windowId of windowsToMinimize) {
|
|
294
|
+
const w = newWindows.get(windowId);
|
|
295
|
+
if (w) {
|
|
296
|
+
newWindows.set(windowId, { ...w, isMinimized: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Focus next topmost non-minimized window
|
|
301
|
+
const visible = Array.from(newWindows.values()).filter(
|
|
302
|
+
(w) => !w.isMinimized
|
|
303
|
+
);
|
|
304
|
+
const newFocusedId =
|
|
305
|
+
visible.length > 0
|
|
306
|
+
? visible.reduce((a, b) => (a.zIndex > b.zIndex ? a : b)).id
|
|
307
|
+
: null;
|
|
308
|
+
|
|
309
|
+
return { windows: newWindows, focusedId: newFocusedId };
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
_maximize: (id) => {
|
|
314
|
+
const { windows, groups } = get();
|
|
315
|
+
const window = windows.get(id);
|
|
316
|
+
if (!window || window.isMaximized) return;
|
|
317
|
+
|
|
318
|
+
const viewport = getViewportSize();
|
|
319
|
+
const windowsToMaximize = window.groupId
|
|
320
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
321
|
+
: [id];
|
|
322
|
+
|
|
323
|
+
set((state) => {
|
|
324
|
+
const newWindows = new Map(state.windows);
|
|
325
|
+
for (const windowId of windowsToMaximize) {
|
|
326
|
+
const w = newWindows.get(windowId);
|
|
327
|
+
if (w && !w.isMaximized) {
|
|
328
|
+
newWindows.set(windowId, {
|
|
329
|
+
...w,
|
|
330
|
+
isMaximized: true,
|
|
331
|
+
preMaximize: {
|
|
332
|
+
x: w.position.x,
|
|
333
|
+
y: w.position.y,
|
|
334
|
+
width: w.size.width,
|
|
335
|
+
height: w.size.height,
|
|
336
|
+
},
|
|
337
|
+
position: { x: 0, y: 0 },
|
|
338
|
+
size: { width: viewport.width, height: viewport.height },
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { windows: newWindows };
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
_restore: (id) => {
|
|
347
|
+
const { windows, groups } = get();
|
|
348
|
+
const window = windows.get(id);
|
|
349
|
+
if (!window) return;
|
|
350
|
+
|
|
351
|
+
const windowsToRestore = window.groupId
|
|
352
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
353
|
+
: [id];
|
|
354
|
+
|
|
355
|
+
set((state) => {
|
|
356
|
+
const newWindows = new Map(state.windows);
|
|
357
|
+
for (const windowId of windowsToRestore) {
|
|
358
|
+
const w = newWindows.get(windowId);
|
|
359
|
+
if (w) {
|
|
360
|
+
if (w.isMaximized && w.preMaximize) {
|
|
361
|
+
newWindows.set(windowId, {
|
|
362
|
+
...w,
|
|
363
|
+
isMaximized: false,
|
|
364
|
+
isMinimized: false,
|
|
365
|
+
position: { x: w.preMaximize.x, y: w.preMaximize.y },
|
|
366
|
+
size: { width: w.preMaximize.width, height: w.preMaximize.height },
|
|
367
|
+
preMaximize: undefined,
|
|
368
|
+
zIndex: state.nextZIndex,
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
newWindows.set(windowId, {
|
|
372
|
+
...w,
|
|
373
|
+
isMinimized: false,
|
|
374
|
+
zIndex: state.nextZIndex,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const group = window.groupId ? state.groups.get(window.groupId) : null;
|
|
381
|
+
return {
|
|
382
|
+
windows: newWindows,
|
|
383
|
+
focusedId: group?.activeWindowId ?? id,
|
|
384
|
+
nextZIndex: state.nextZIndex + 1,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
_fullscreen: (id) => {
|
|
390
|
+
const { windows, fullscreenId } = get();
|
|
391
|
+
const window = windows.get(id);
|
|
392
|
+
if (!window) return;
|
|
393
|
+
|
|
394
|
+
// Exit current fullscreen first
|
|
395
|
+
if (fullscreenId && fullscreenId !== id) {
|
|
396
|
+
get()._exitFullscreen();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (window.isFullscreen) return;
|
|
400
|
+
|
|
401
|
+
// Request browser fullscreen
|
|
402
|
+
if (typeof document !== 'undefined') {
|
|
403
|
+
document.documentElement.requestFullscreen?.().catch(() => {});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const screenWidth =
|
|
407
|
+
typeof globalThis.innerWidth !== 'undefined'
|
|
408
|
+
? globalThis.innerWidth
|
|
409
|
+
: 1920;
|
|
410
|
+
const screenHeight =
|
|
411
|
+
typeof globalThis.innerHeight !== 'undefined'
|
|
412
|
+
? globalThis.innerHeight
|
|
413
|
+
: 1080;
|
|
414
|
+
|
|
415
|
+
set((state) => {
|
|
416
|
+
const newWindows = new Map(state.windows);
|
|
417
|
+
const w = newWindows.get(id);
|
|
418
|
+
if (w) {
|
|
419
|
+
newWindows.set(id, {
|
|
420
|
+
...w,
|
|
421
|
+
isFullscreen: true,
|
|
422
|
+
isMaximized: false,
|
|
423
|
+
tilePosition: undefined,
|
|
424
|
+
preFullscreen: {
|
|
425
|
+
x: w.position.x,
|
|
426
|
+
y: w.position.y,
|
|
427
|
+
width: w.size.width,
|
|
428
|
+
height: w.size.height,
|
|
429
|
+
},
|
|
430
|
+
position: { x: 0, y: 0 },
|
|
431
|
+
size: { width: screenWidth, height: screenHeight },
|
|
432
|
+
zIndex: 10000,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
windows: newWindows,
|
|
437
|
+
fullscreenId: id,
|
|
438
|
+
focusedId: id,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
_exitFullscreen: () => {
|
|
444
|
+
const { fullscreenId } = get();
|
|
445
|
+
if (!fullscreenId) return;
|
|
446
|
+
|
|
447
|
+
// Exit browser fullscreen
|
|
448
|
+
if (typeof document !== 'undefined' && document.fullscreenElement) {
|
|
449
|
+
document.exitFullscreen?.().catch(() => {});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
set((state) => {
|
|
453
|
+
const newWindows = new Map(state.windows);
|
|
454
|
+
const w = newWindows.get(fullscreenId);
|
|
455
|
+
if (w) {
|
|
456
|
+
const pre = w.preFullscreen;
|
|
457
|
+
newWindows.set(fullscreenId, {
|
|
458
|
+
...w,
|
|
459
|
+
isFullscreen: false,
|
|
460
|
+
position: { x: pre?.x ?? 100, y: pre?.y ?? 100 },
|
|
461
|
+
size: { width: pre?.width ?? 800, height: pre?.height ?? 600 },
|
|
462
|
+
preFullscreen: undefined,
|
|
463
|
+
zIndex: state.nextZIndex,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
windows: newWindows,
|
|
468
|
+
fullscreenId: null,
|
|
469
|
+
nextZIndex: state.nextZIndex + 1,
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
_move: (id, position) => {
|
|
475
|
+
const { windows, groups } = get();
|
|
476
|
+
const window = windows.get(id);
|
|
477
|
+
if (!window || window.isMaximized) return;
|
|
478
|
+
|
|
479
|
+
const windowsToMove = window.groupId
|
|
480
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
481
|
+
: [id];
|
|
482
|
+
|
|
483
|
+
set((state) => {
|
|
484
|
+
const newWindows = new Map(state.windows);
|
|
485
|
+
for (const windowId of windowsToMove) {
|
|
486
|
+
const w = newWindows.get(windowId);
|
|
487
|
+
if (w) {
|
|
488
|
+
newWindows.set(windowId, { ...w, position });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return { windows: newWindows };
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
_resize: (id, size) => {
|
|
496
|
+
const { windows, groups } = get();
|
|
497
|
+
const window = windows.get(id);
|
|
498
|
+
if (!window || window.isMaximized) return;
|
|
499
|
+
|
|
500
|
+
const windowsToResize = window.groupId
|
|
501
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
502
|
+
: [id];
|
|
503
|
+
|
|
504
|
+
set((state) => {
|
|
505
|
+
const newWindows = new Map(state.windows);
|
|
506
|
+
for (const windowId of windowsToResize) {
|
|
507
|
+
const w = newWindows.get(windowId);
|
|
508
|
+
if (w) {
|
|
509
|
+
newWindows.set(windowId, { ...w, size });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { windows: newWindows };
|
|
513
|
+
});
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
_tile: (id, position) => {
|
|
517
|
+
const { windows, groups } = get();
|
|
518
|
+
const window = windows.get(id);
|
|
519
|
+
if (!window) return;
|
|
520
|
+
|
|
521
|
+
const tileDims = getTileDimensions(position);
|
|
522
|
+
const windowsToTile = window.groupId
|
|
523
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
524
|
+
: [id];
|
|
525
|
+
|
|
526
|
+
set((state) => {
|
|
527
|
+
const newWindows = new Map(state.windows);
|
|
528
|
+
for (const windowId of windowsToTile) {
|
|
529
|
+
const w = newWindows.get(windowId);
|
|
530
|
+
if (w) {
|
|
531
|
+
const preTile = w.tilePosition
|
|
532
|
+
? w.preTile
|
|
533
|
+
: {
|
|
534
|
+
x: w.position.x,
|
|
535
|
+
y: w.position.y,
|
|
536
|
+
width: w.size.width,
|
|
537
|
+
height: w.size.height,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
newWindows.set(windowId, {
|
|
541
|
+
...w,
|
|
542
|
+
position: { x: tileDims.x, y: tileDims.y },
|
|
543
|
+
size: { width: tileDims.width, height: tileDims.height },
|
|
544
|
+
tilePosition: position,
|
|
545
|
+
preTile,
|
|
546
|
+
isMaximized: false,
|
|
547
|
+
preMaximize: undefined,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return { windows: newWindows };
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
_untile: (id) => {
|
|
556
|
+
const { windows, groups } = get();
|
|
557
|
+
const window = windows.get(id);
|
|
558
|
+
if (!window || !window.tilePosition || !window.preTile) return;
|
|
559
|
+
|
|
560
|
+
const windowsToUntile = window.groupId
|
|
561
|
+
? groups.get(window.groupId)?.windowIds ?? [id]
|
|
562
|
+
: [id];
|
|
563
|
+
|
|
564
|
+
set((state) => {
|
|
565
|
+
const newWindows = new Map(state.windows);
|
|
566
|
+
for (const windowId of windowsToUntile) {
|
|
567
|
+
const w = newWindows.get(windowId);
|
|
568
|
+
if (w && w.tilePosition && w.preTile) {
|
|
569
|
+
newWindows.set(windowId, {
|
|
570
|
+
...w,
|
|
571
|
+
position: { x: w.preTile.x, y: w.preTile.y },
|
|
572
|
+
size: { width: w.preTile.width, height: w.preTile.height },
|
|
573
|
+
tilePosition: undefined,
|
|
574
|
+
preTile: undefined,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { windows: newWindows };
|
|
579
|
+
});
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
_cycleFocusNext: () => {
|
|
583
|
+
const { windows, focusedId } = get();
|
|
584
|
+
const windowList = Array.from(windows.values())
|
|
585
|
+
.filter((w) => !w.isMinimized)
|
|
586
|
+
.sort((a, b) => b.zIndex - a.zIndex);
|
|
587
|
+
|
|
588
|
+
if (windowList.length <= 1) return;
|
|
589
|
+
|
|
590
|
+
const currentIndex = windowList.findIndex((w) => w.id === focusedId);
|
|
591
|
+
const nextIndex = (currentIndex + 1) % windowList.length;
|
|
592
|
+
get()._focus(windowList[nextIndex].id);
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
_createGroup: (windowId1, windowId2) => {
|
|
596
|
+
const { windows } = get();
|
|
597
|
+
const window1 = windows.get(windowId1);
|
|
598
|
+
const window2 = windows.get(windowId2);
|
|
599
|
+
|
|
600
|
+
if (!window1 || !window2 || windowId1 === windowId2) return null;
|
|
601
|
+
|
|
602
|
+
// If either window is already in a group, add to that group
|
|
603
|
+
if (window1.groupId) {
|
|
604
|
+
get()._addToGroup(window1.groupId, windowId2);
|
|
605
|
+
return window1.groupId;
|
|
606
|
+
}
|
|
607
|
+
if (window2.groupId) {
|
|
608
|
+
get()._addToGroup(window2.groupId, windowId1);
|
|
609
|
+
return window2.groupId;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const groupId = generateGroupId();
|
|
613
|
+
const newGroup: WindowGroup = {
|
|
614
|
+
id: groupId,
|
|
615
|
+
windowIds: [windowId1, windowId2],
|
|
616
|
+
activeWindowId: windowId1,
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
set((state) => {
|
|
620
|
+
const newWindows = new Map(state.windows);
|
|
621
|
+
const newGroups = new Map(state.groups);
|
|
622
|
+
|
|
623
|
+
// Use window2's position/size as the container
|
|
624
|
+
newWindows.set(windowId1, {
|
|
625
|
+
...window1,
|
|
626
|
+
groupId,
|
|
627
|
+
isGroupActive: true,
|
|
628
|
+
position: { ...window2.position },
|
|
629
|
+
size: { ...window2.size },
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
newWindows.set(windowId2, {
|
|
633
|
+
...window2,
|
|
634
|
+
groupId,
|
|
635
|
+
isGroupActive: false,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
newGroups.set(groupId, newGroup);
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
windows: newWindows,
|
|
642
|
+
groups: newGroups,
|
|
643
|
+
focusedId: windowId1,
|
|
644
|
+
nextZIndex: state.nextZIndex + 1,
|
|
645
|
+
};
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return groupId;
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
_addToGroup: (groupId, windowId) => {
|
|
652
|
+
const { windows, groups } = get();
|
|
653
|
+
const window = windows.get(windowId);
|
|
654
|
+
const group = groups.get(groupId);
|
|
655
|
+
|
|
656
|
+
if (!window || !group || window.groupId === groupId) return;
|
|
657
|
+
|
|
658
|
+
// Remove from existing group first
|
|
659
|
+
if (window.groupId) {
|
|
660
|
+
get()._removeFromGroup(windowId);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
set((state) => {
|
|
664
|
+
const newWindows = new Map(state.windows);
|
|
665
|
+
const newGroups = new Map(state.groups);
|
|
666
|
+
const currentGroup = state.groups.get(groupId);
|
|
667
|
+
if (!currentGroup) return state;
|
|
668
|
+
|
|
669
|
+
const existingMember = state.windows.get(currentGroup.windowIds[0]);
|
|
670
|
+
if (!existingMember) return state;
|
|
671
|
+
|
|
672
|
+
newWindows.set(windowId, {
|
|
673
|
+
...window,
|
|
674
|
+
groupId,
|
|
675
|
+
isGroupActive: false,
|
|
676
|
+
position: { ...existingMember.position },
|
|
677
|
+
size: { ...existingMember.size },
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
newGroups.set(groupId, {
|
|
681
|
+
...currentGroup,
|
|
682
|
+
windowIds: [...currentGroup.windowIds, windowId],
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
return { windows: newWindows, groups: newGroups };
|
|
686
|
+
});
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
_removeFromGroup: (windowId) => {
|
|
690
|
+
const { windows, groups } = get();
|
|
691
|
+
const window = windows.get(windowId);
|
|
692
|
+
|
|
693
|
+
if (!window || !window.groupId) return;
|
|
694
|
+
|
|
695
|
+
const group = groups.get(window.groupId);
|
|
696
|
+
if (!group) return;
|
|
697
|
+
|
|
698
|
+
set((state) => {
|
|
699
|
+
const newWindows = new Map(state.windows);
|
|
700
|
+
const newGroups = new Map(state.groups);
|
|
701
|
+
const currentGroup = state.groups.get(window.groupId!);
|
|
702
|
+
if (!currentGroup) return state;
|
|
703
|
+
|
|
704
|
+
const remainingWindowIds = currentGroup.windowIds.filter(
|
|
705
|
+
(id) => id !== windowId
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
// Clear group info from this window
|
|
709
|
+
newWindows.set(windowId, {
|
|
710
|
+
...window,
|
|
711
|
+
groupId: undefined,
|
|
712
|
+
isGroupActive: undefined,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
if (remainingWindowIds.length <= 1) {
|
|
716
|
+
// Dissolve the group
|
|
717
|
+
for (const wId of remainingWindowIds) {
|
|
718
|
+
const w = newWindows.get(wId);
|
|
719
|
+
if (w) {
|
|
720
|
+
newWindows.set(wId, {
|
|
721
|
+
...w,
|
|
722
|
+
groupId: undefined,
|
|
723
|
+
isGroupActive: undefined,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
newGroups.delete(window.groupId!);
|
|
728
|
+
} else {
|
|
729
|
+
let newActiveId = currentGroup.activeWindowId;
|
|
730
|
+
if (currentGroup.activeWindowId === windowId) {
|
|
731
|
+
newActiveId = remainingWindowIds[0];
|
|
732
|
+
for (const wId of remainingWindowIds) {
|
|
733
|
+
const w = newWindows.get(wId);
|
|
734
|
+
if (w) {
|
|
735
|
+
newWindows.set(wId, {
|
|
736
|
+
...w,
|
|
737
|
+
isGroupActive: wId === newActiveId,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
newGroups.set(window.groupId!, {
|
|
744
|
+
...currentGroup,
|
|
745
|
+
windowIds: remainingWindowIds,
|
|
746
|
+
activeWindowId: newActiveId,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return { windows: newWindows, groups: newGroups };
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
_setActiveTab: (groupId, windowId) => {
|
|
755
|
+
const { windows, groups } = get();
|
|
756
|
+
const group = groups.get(groupId);
|
|
757
|
+
const window = windows.get(windowId);
|
|
758
|
+
|
|
759
|
+
if (!group || !window) return;
|
|
760
|
+
if (!group.windowIds.includes(windowId)) return;
|
|
761
|
+
if (group.activeWindowId === windowId) return;
|
|
762
|
+
|
|
763
|
+
set((state) => {
|
|
764
|
+
const newWindows = new Map(state.windows);
|
|
765
|
+
const newGroups = new Map(state.groups);
|
|
766
|
+
const currentGroup = state.groups.get(groupId);
|
|
767
|
+
if (!currentGroup) return state;
|
|
768
|
+
|
|
769
|
+
for (const wId of currentGroup.windowIds) {
|
|
770
|
+
const w = newWindows.get(wId);
|
|
771
|
+
if (w) {
|
|
772
|
+
newWindows.set(wId, {
|
|
773
|
+
...w,
|
|
774
|
+
isGroupActive: wId === windowId,
|
|
775
|
+
zIndex: wId === windowId ? state.nextZIndex : w.zIndex,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
newGroups.set(groupId, {
|
|
781
|
+
...currentGroup,
|
|
782
|
+
activeWindowId: windowId,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
windows: newWindows,
|
|
787
|
+
groups: newGroups,
|
|
788
|
+
focusedId: windowId,
|
|
789
|
+
nextZIndex: state.nextZIndex + 1,
|
|
790
|
+
};
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
_minimizeAll: () => {
|
|
795
|
+
set((state) => {
|
|
796
|
+
const newWindows = new Map(state.windows);
|
|
797
|
+
for (const [id, w] of newWindows) {
|
|
798
|
+
newWindows.set(id, { ...w, isMinimized: true });
|
|
799
|
+
}
|
|
800
|
+
return { windows: newWindows, focusedId: null };
|
|
801
|
+
});
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
_closeAll: () => {
|
|
805
|
+
// Exit fullscreen first
|
|
806
|
+
get()._exitFullscreen();
|
|
807
|
+
set(() => ({
|
|
808
|
+
windows: new Map(),
|
|
809
|
+
groups: new Map(),
|
|
810
|
+
focusedId: null,
|
|
811
|
+
fullscreenId: null,
|
|
812
|
+
}));
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
_cascade: () => {
|
|
816
|
+
set((state) => {
|
|
817
|
+
const windowList = Array.from(state.windows.values())
|
|
818
|
+
.filter((w) => !w.isMinimized)
|
|
819
|
+
.sort((a, b) => a.zIndex - b.zIndex);
|
|
820
|
+
|
|
821
|
+
const newWindows = new Map(state.windows);
|
|
822
|
+
let z = state.nextZIndex;
|
|
823
|
+
windowList.forEach((w, i) => {
|
|
824
|
+
const offset = i * CASCADE_OFFSET;
|
|
825
|
+
newWindows.set(w.id, {
|
|
826
|
+
...w,
|
|
827
|
+
position: { x: 50 + offset, y: 50 + offset },
|
|
828
|
+
isMaximized: false,
|
|
829
|
+
tilePosition: undefined,
|
|
830
|
+
zIndex: z++,
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
return { windows: newWindows, nextZIndex: z };
|
|
834
|
+
});
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
_tileAll: (layout) => {
|
|
838
|
+
const { windows } = get();
|
|
839
|
+
const windowList = Array.from(windows.values()).filter(
|
|
840
|
+
(w) => !w.isMinimized
|
|
841
|
+
);
|
|
842
|
+
if (windowList.length === 0) return;
|
|
843
|
+
|
|
844
|
+
const viewport = getViewportSize();
|
|
845
|
+
|
|
846
|
+
set((state: WindowManagerStore) => {
|
|
847
|
+
const newWindows = new Map(state.windows);
|
|
848
|
+
const count = windowList.length;
|
|
849
|
+
|
|
850
|
+
if (layout === 'horizontal') {
|
|
851
|
+
const width = viewport.width / count;
|
|
852
|
+
windowList.forEach((w, i) => {
|
|
853
|
+
newWindows.set(w.id, {
|
|
854
|
+
...w,
|
|
855
|
+
position: { x: i * width, y: 0 },
|
|
856
|
+
size: { width, height: viewport.height },
|
|
857
|
+
isMaximized: false,
|
|
858
|
+
tilePosition: undefined,
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
} else if (layout === 'vertical') {
|
|
862
|
+
const height = viewport.height / count;
|
|
863
|
+
windowList.forEach((w, i) => {
|
|
864
|
+
newWindows.set(w.id, {
|
|
865
|
+
...w,
|
|
866
|
+
position: { x: 0, y: i * height },
|
|
867
|
+
size: { width: viewport.width, height },
|
|
868
|
+
isMaximized: false,
|
|
869
|
+
tilePosition: undefined,
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
} else {
|
|
873
|
+
// Grid
|
|
874
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
875
|
+
const rows = Math.ceil(count / cols);
|
|
876
|
+
const cellWidth = viewport.width / cols;
|
|
877
|
+
const cellHeight = viewport.height / rows;
|
|
878
|
+
|
|
879
|
+
windowList.forEach((w, i) => {
|
|
880
|
+
const col = i % cols;
|
|
881
|
+
const row = Math.floor(i / cols);
|
|
882
|
+
newWindows.set(w.id, {
|
|
883
|
+
...w,
|
|
884
|
+
position: { x: col * cellWidth, y: row * cellHeight },
|
|
885
|
+
size: { width: cellWidth, height: cellHeight },
|
|
886
|
+
isMaximized: false,
|
|
887
|
+
tilePosition: undefined,
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return { windows: newWindows };
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Factory: creates an isolated WindowManager store instance. */
|
|
899
|
+
export function createWindowManagerStore() {
|
|
900
|
+
return createStore<WindowManagerStore>((set, get) => createWindowManagerStoreImpl(set, get));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export type WindowManagerStoreApi = ReturnType<typeof createWindowManagerStore>;
|
|
904
|
+
|
|
905
|
+
// Context for provider-scoped usage
|
|
906
|
+
const WindowManagerStoreContext = createContext<WindowManagerStoreApi | null>(null);
|
|
907
|
+
|
|
908
|
+
/** Provider that creates an isolated WindowManager store for its subtree. */
|
|
909
|
+
export function WindowManagerStoreProvider({ children }: { children: ReactNode }) {
|
|
910
|
+
const [store] = useState(() => createWindowManagerStore());
|
|
911
|
+
return (
|
|
912
|
+
<WindowManagerStoreContext.Provider value={store}>
|
|
913
|
+
{children}
|
|
914
|
+
</WindowManagerStoreContext.Provider>
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Legacy singleton (kept for backwards compatibility and barrel exports)
|
|
919
|
+
export const useWindowManagerStore = create<WindowManagerStore>((set, get) => createWindowManagerStoreImpl(set, get));
|
|
920
|
+
|
|
921
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
922
|
+
// Context-aware store resolver
|
|
923
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
924
|
+
|
|
925
|
+
/** Returns the context-provided store if available, otherwise the singleton. */
|
|
926
|
+
function useResolvedWindowManagerStore(): StoreApi<WindowManagerStore> {
|
|
927
|
+
const contextStore = useContext(WindowManagerStoreContext);
|
|
928
|
+
return contextStore ?? useWindowManagerStore;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
932
|
+
// Public Hook
|
|
933
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Hook for window management.
|
|
937
|
+
*
|
|
938
|
+
* Provides all window lifecycle, focus, and state management operations.
|
|
939
|
+
*
|
|
940
|
+
* @example
|
|
941
|
+
* ```tsx
|
|
942
|
+
* const { windows, open, close, focus } = useWindowManager();
|
|
943
|
+
*
|
|
944
|
+
* const handleOpenWindow = () => {
|
|
945
|
+
* const id = open({ title: 'My Window', size: { width: 800, height: 600 } });
|
|
946
|
+
* console.log('Opened window:', id);
|
|
947
|
+
* };
|
|
948
|
+
* ```
|
|
949
|
+
*/
|
|
950
|
+
export function useWindowManager(): UseWindowManagerReturn {
|
|
951
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
952
|
+
const store = useStore(
|
|
953
|
+
resolvedStore,
|
|
954
|
+
useShallow((state) => ({
|
|
955
|
+
windows: state.windows,
|
|
956
|
+
groups: state.groups,
|
|
957
|
+
focusedId: state.focusedId,
|
|
958
|
+
fullscreenId: state.fullscreenId,
|
|
959
|
+
open: state._open,
|
|
960
|
+
close: state._close,
|
|
961
|
+
focus: state._focus,
|
|
962
|
+
minimize: state._minimize,
|
|
963
|
+
maximize: state._maximize,
|
|
964
|
+
restore: state._restore,
|
|
965
|
+
fullscreen: state._fullscreen,
|
|
966
|
+
exitFullscreen: state._exitFullscreen,
|
|
967
|
+
move: state._move,
|
|
968
|
+
resize: state._resize,
|
|
969
|
+
tile: state._tile,
|
|
970
|
+
untile: state._untile,
|
|
971
|
+
cycleFocusNext: state._cycleFocusNext,
|
|
972
|
+
createGroup: state._createGroup,
|
|
973
|
+
addToGroup: state._addToGroup,
|
|
974
|
+
removeFromGroup: state._removeFromGroup,
|
|
975
|
+
setActiveTab: state._setActiveTab,
|
|
976
|
+
minimizeAll: state._minimizeAll,
|
|
977
|
+
closeAll: state._closeAll,
|
|
978
|
+
cascade: state._cascade,
|
|
979
|
+
tileAll: state._tileAll,
|
|
980
|
+
})),
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
windows: Array.from(store.windows.values()),
|
|
985
|
+
focusedId: store.focusedId,
|
|
986
|
+
fullscreenId: store.fullscreenId,
|
|
987
|
+
groups: Array.from(store.groups.values()),
|
|
988
|
+
open: store.open,
|
|
989
|
+
close: store.close,
|
|
990
|
+
focus: store.focus,
|
|
991
|
+
cycleFocusNext: store.cycleFocusNext,
|
|
992
|
+
minimize: store.minimize,
|
|
993
|
+
maximize: store.maximize,
|
|
994
|
+
restore: store.restore,
|
|
995
|
+
fullscreen: store.fullscreen,
|
|
996
|
+
exitFullscreen: store.exitFullscreen,
|
|
997
|
+
move: store.move,
|
|
998
|
+
resize: store.resize,
|
|
999
|
+
tile: store.tile,
|
|
1000
|
+
untile: store.untile,
|
|
1001
|
+
createGroup: store.createGroup,
|
|
1002
|
+
addToGroup: store.addToGroup,
|
|
1003
|
+
removeFromGroup: store.removeFromGroup,
|
|
1004
|
+
setActiveTab: store.setActiveTab,
|
|
1005
|
+
minimizeAll: store.minimizeAll,
|
|
1006
|
+
closeAll: store.closeAll,
|
|
1007
|
+
cascade: store.cascade,
|
|
1008
|
+
tileAll: store.tileAll,
|
|
1009
|
+
getWindow: (id) => store.windows.get(id),
|
|
1010
|
+
getWindowGroup: (windowId) => {
|
|
1011
|
+
const w = store.windows.get(windowId);
|
|
1012
|
+
return w?.groupId ? store.groups.get(w.groupId) : undefined;
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1018
|
+
// Optimized Selector Hooks
|
|
1019
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Get array of window IDs - only re-renders when IDs change.
|
|
1023
|
+
*
|
|
1024
|
+
* @returns Array of all window IDs in the system
|
|
1025
|
+
*
|
|
1026
|
+
* @example
|
|
1027
|
+
* ```tsx
|
|
1028
|
+
* const windowIds = useWindowIds();
|
|
1029
|
+
* return windowIds.map(id => <WindowListItem key={id} windowId={id} />);
|
|
1030
|
+
* ```
|
|
1031
|
+
*/
|
|
1032
|
+
export function useWindowIds(): WindowId[] {
|
|
1033
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1034
|
+
return useStore(
|
|
1035
|
+
resolvedStore,
|
|
1036
|
+
useShallow((state) => Array.from(state.windows.keys())),
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Get a single window by ID - only re-renders when that window changes.
|
|
1042
|
+
*
|
|
1043
|
+
* @param id - The window ID to retrieve
|
|
1044
|
+
* @returns The window state or undefined if not found
|
|
1045
|
+
*
|
|
1046
|
+
* @example
|
|
1047
|
+
* ```tsx
|
|
1048
|
+
* const window = useWindow(windowId);
|
|
1049
|
+
* if (!window) return null;
|
|
1050
|
+
* return <div>{window.title}</div>;
|
|
1051
|
+
* ```
|
|
1052
|
+
*/
|
|
1053
|
+
export function useWindow(id: WindowId): WindowState | undefined {
|
|
1054
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1055
|
+
return useStore(resolvedStore, (state) => state.windows.get(id));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Check if a specific window is focused.
|
|
1060
|
+
*
|
|
1061
|
+
* @param id - The window ID to check
|
|
1062
|
+
* @returns True if the window is currently focused
|
|
1063
|
+
*
|
|
1064
|
+
* @example
|
|
1065
|
+
* ```tsx
|
|
1066
|
+
* const isFocused = useIsWindowFocused(windowId);
|
|
1067
|
+
* return <div className={isFocused ? 'focused' : ''}>...</div>;
|
|
1068
|
+
* ```
|
|
1069
|
+
*/
|
|
1070
|
+
export function useIsWindowFocused(id: WindowId): boolean {
|
|
1071
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1072
|
+
return useStore(resolvedStore, (state) => state.focusedId === id);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Check if a specific window is fullscreen.
|
|
1077
|
+
*
|
|
1078
|
+
* @param id - The window ID to check
|
|
1079
|
+
* @returns True if the window is in fullscreen mode
|
|
1080
|
+
*/
|
|
1081
|
+
export function useIsWindowFullscreen(id: WindowId): boolean {
|
|
1082
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1083
|
+
return useStore(resolvedStore, (state) => state.fullscreenId === id);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Check if any window is in fullscreen mode.
|
|
1088
|
+
*
|
|
1089
|
+
* @returns True if any window is currently fullscreen
|
|
1090
|
+
*
|
|
1091
|
+
* @example
|
|
1092
|
+
* ```tsx
|
|
1093
|
+
* const isFullscreen = useIsFullscreenActive();
|
|
1094
|
+
* return isFullscreen ? <FullscreenOverlay /> : <Taskbar />;
|
|
1095
|
+
* ```
|
|
1096
|
+
*/
|
|
1097
|
+
export function useIsFullscreenActive(): boolean {
|
|
1098
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1099
|
+
return useStore(resolvedStore, (state) => state.fullscreenId !== null);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Get window actions with stable references.
|
|
1104
|
+
* Useful when you only need actions without subscribing to state changes.
|
|
1105
|
+
*
|
|
1106
|
+
* @returns Object containing all window action methods
|
|
1107
|
+
*
|
|
1108
|
+
* @example
|
|
1109
|
+
* ```tsx
|
|
1110
|
+
* const { close, minimize, maximize } = useWindowActions();
|
|
1111
|
+
* return <button onClick={() => close(windowId)}>Close</button>;
|
|
1112
|
+
* ```
|
|
1113
|
+
*/
|
|
1114
|
+
export function useWindowActions() {
|
|
1115
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1116
|
+
return useStore(
|
|
1117
|
+
resolvedStore,
|
|
1118
|
+
useShallow((state) => ({
|
|
1119
|
+
open: state._open,
|
|
1120
|
+
close: state._close,
|
|
1121
|
+
focus: state._focus,
|
|
1122
|
+
minimize: state._minimize,
|
|
1123
|
+
maximize: state._maximize,
|
|
1124
|
+
restore: state._restore,
|
|
1125
|
+
move: state._move,
|
|
1126
|
+
resize: state._resize,
|
|
1127
|
+
tile: state._tile,
|
|
1128
|
+
untile: state._untile,
|
|
1129
|
+
fullscreen: state._fullscreen,
|
|
1130
|
+
exitFullscreen: state._exitFullscreen,
|
|
1131
|
+
})),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Get a window group by ID.
|
|
1137
|
+
*
|
|
1138
|
+
* @param groupId - The group ID to retrieve, or undefined
|
|
1139
|
+
* @returns The window group or undefined if not found
|
|
1140
|
+
*
|
|
1141
|
+
* @example
|
|
1142
|
+
* ```tsx
|
|
1143
|
+
* const group = useWindowGroup(window.groupId);
|
|
1144
|
+
* if (group) {
|
|
1145
|
+
* return <TabBar windowIds={group.windowIds} activeId={group.activeWindowId} />;
|
|
1146
|
+
* }
|
|
1147
|
+
* ```
|
|
1148
|
+
*/
|
|
1149
|
+
export function useWindowGroup(groupId: string | undefined): WindowGroup | undefined {
|
|
1150
|
+
const resolvedStore = useResolvedWindowManagerStore();
|
|
1151
|
+
return useStore(resolvedStore, (state) =>
|
|
1152
|
+
groupId ? state.groups.get(groupId) : undefined,
|
|
1153
|
+
);
|
|
1154
|
+
}
|