@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
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@backbay/glia-desktop",
|
|
3
|
+
"version": "0.2.0-alpha.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
12
|
+
"./core": { "types": "./dist/core/index.d.ts", "import": "./dist/core/index.js" },
|
|
13
|
+
"./themes": { "types": "./dist/themes/index.d.ts", "import": "./dist/themes/index.js" }
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"zustand": "^5.0.5",
|
|
21
|
+
"class-variance-authority": "^0.7.1",
|
|
22
|
+
"clsx": "^2.1.1",
|
|
23
|
+
"tailwind-merge": "^3.3.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^18 || ^19",
|
|
27
|
+
"react-dom": "^18 || ^19",
|
|
28
|
+
"framer-motion": "^12"
|
|
29
|
+
},
|
|
30
|
+
"optionalDependencies": {
|
|
31
|
+
"react-rnd": "^10.4.13"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"tsup": "^8.5.1",
|
|
35
|
+
"typescript": "^5.8.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { Component, type ReactNode, type ErrorInfo } from "react";
|
|
4
|
+
import { ErrorThrower } from "./useErrorBoundary";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// TYPES
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export interface ErrorFallbackProps {
|
|
11
|
+
error: Error;
|
|
12
|
+
reset: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GliaErrorBoundaryProps {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
/** Custom fallback UI. Receives error + reset function. */
|
|
18
|
+
fallback?: ReactNode | ((props: ErrorFallbackProps) => ReactNode);
|
|
19
|
+
/** Called when an error is caught. Use for telemetry/logging. */
|
|
20
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
21
|
+
/** Auto-retry after this many ms (0 = no auto-retry) */
|
|
22
|
+
autoRetryMs?: number;
|
|
23
|
+
/** Glass-themed fallback styling */
|
|
24
|
+
variant?: "inline" | "card" | "fullscreen";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GliaErrorBoundaryState {
|
|
28
|
+
error: Error | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// DEFAULT FALLBACK (glass-themed)
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
function DefaultFallback({
|
|
36
|
+
error,
|
|
37
|
+
reset,
|
|
38
|
+
variant = "card",
|
|
39
|
+
}: ErrorFallbackProps & { variant?: "inline" | "card" | "fullscreen" }) {
|
|
40
|
+
const isFullscreen = variant === "fullscreen";
|
|
41
|
+
const isInline = variant === "inline";
|
|
42
|
+
|
|
43
|
+
const containerStyle: React.CSSProperties = isFullscreen
|
|
44
|
+
? {
|
|
45
|
+
position: "fixed",
|
|
46
|
+
inset: 0,
|
|
47
|
+
display: "flex",
|
|
48
|
+
alignItems: "center",
|
|
49
|
+
justifyContent: "center",
|
|
50
|
+
zIndex: 9999,
|
|
51
|
+
background: "rgba(0, 0, 0, 0.6)",
|
|
52
|
+
backdropFilter: "blur(12px)",
|
|
53
|
+
WebkitBackdropFilter: "blur(12px)",
|
|
54
|
+
}
|
|
55
|
+
: isInline
|
|
56
|
+
? {
|
|
57
|
+
display: "inline-flex",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
gap: 8,
|
|
60
|
+
padding: "4px 8px",
|
|
61
|
+
borderRadius: 6,
|
|
62
|
+
background: "rgba(255, 60, 60, 0.08)",
|
|
63
|
+
border: "1px solid rgba(255, 60, 60, 0.2)",
|
|
64
|
+
}
|
|
65
|
+
: {};
|
|
66
|
+
|
|
67
|
+
const cardStyle: React.CSSProperties = {
|
|
68
|
+
background: "rgba(20, 20, 30, 0.85)",
|
|
69
|
+
backdropFilter: "blur(16px)",
|
|
70
|
+
WebkitBackdropFilter: "blur(16px)",
|
|
71
|
+
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
72
|
+
borderRadius: 12,
|
|
73
|
+
padding: isInline ? undefined : "24px 28px",
|
|
74
|
+
maxWidth: isFullscreen ? 480 : "100%",
|
|
75
|
+
width: isFullscreen ? "90%" : undefined,
|
|
76
|
+
color: "rgba(255, 255, 255, 0.9)",
|
|
77
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (isInline) {
|
|
81
|
+
return (
|
|
82
|
+
<span style={containerStyle}>
|
|
83
|
+
<span style={{ color: "rgba(255, 100, 100, 0.9)", fontSize: 12 }}>
|
|
84
|
+
Error: {error.message}
|
|
85
|
+
</span>
|
|
86
|
+
<button
|
|
87
|
+
onClick={reset}
|
|
88
|
+
style={{
|
|
89
|
+
background: "none",
|
|
90
|
+
border: "none",
|
|
91
|
+
color: "rgba(100, 180, 255, 0.9)",
|
|
92
|
+
cursor: "pointer",
|
|
93
|
+
fontSize: 12,
|
|
94
|
+
textDecoration: "underline",
|
|
95
|
+
padding: 0,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
Retry
|
|
99
|
+
</button>
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div style={containerStyle}>
|
|
106
|
+
<div style={cardStyle}>
|
|
107
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
|
108
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(255, 100, 100, 0.9)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
109
|
+
<circle cx="12" cy="12" r="10" />
|
|
110
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
111
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
112
|
+
</svg>
|
|
113
|
+
<span style={{ fontWeight: 600, fontSize: 15 }}>Something went wrong</span>
|
|
114
|
+
</div>
|
|
115
|
+
<p style={{ color: "rgba(255, 255, 255, 0.5)", fontSize: 13, lineHeight: 1.5, margin: "0 0 16px", wordBreak: "break-word" }}>
|
|
116
|
+
{error.message}
|
|
117
|
+
</p>
|
|
118
|
+
<button
|
|
119
|
+
onClick={reset}
|
|
120
|
+
style={{
|
|
121
|
+
background: "rgba(255, 255, 255, 0.08)",
|
|
122
|
+
border: "1px solid rgba(255, 255, 255, 0.12)",
|
|
123
|
+
borderRadius: 8,
|
|
124
|
+
padding: "8px 16px",
|
|
125
|
+
color: "rgba(255, 255, 255, 0.9)",
|
|
126
|
+
cursor: "pointer",
|
|
127
|
+
fontSize: 13,
|
|
128
|
+
fontWeight: 500,
|
|
129
|
+
transition: "background 0.15s",
|
|
130
|
+
}}
|
|
131
|
+
onMouseEnter={(e) => {
|
|
132
|
+
(e.currentTarget as HTMLElement).style.background = "rgba(255, 255, 255, 0.14)";
|
|
133
|
+
}}
|
|
134
|
+
onMouseLeave={(e) => {
|
|
135
|
+
(e.currentTarget as HTMLElement).style.background = "rgba(255, 255, 255, 0.08)";
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
Try again
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// ERROR BOUNDARY (class component)
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
export class GliaErrorBoundary extends Component<GliaErrorBoundaryProps, GliaErrorBoundaryState> {
|
|
150
|
+
static displayName = "GliaErrorBoundary";
|
|
151
|
+
private autoRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
152
|
+
|
|
153
|
+
constructor(props: GliaErrorBoundaryProps) {
|
|
154
|
+
super(props);
|
|
155
|
+
this.state = { error: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static getDerivedStateFromError(error: Error): GliaErrorBoundaryState {
|
|
159
|
+
return { error };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
163
|
+
this.props.onError?.(error, errorInfo);
|
|
164
|
+
|
|
165
|
+
if (this.props.autoRetryMs && this.props.autoRetryMs > 0) {
|
|
166
|
+
this.autoRetryTimer = setTimeout(() => {
|
|
167
|
+
this.reset();
|
|
168
|
+
}, this.props.autoRetryMs);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
componentWillUnmount(): void {
|
|
173
|
+
if (this.autoRetryTimer) {
|
|
174
|
+
clearTimeout(this.autoRetryTimer);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
reset = () => {
|
|
179
|
+
if (this.autoRetryTimer) {
|
|
180
|
+
clearTimeout(this.autoRetryTimer);
|
|
181
|
+
this.autoRetryTimer = null;
|
|
182
|
+
}
|
|
183
|
+
this.setState({ error: null });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
render() {
|
|
187
|
+
const { error } = this.state;
|
|
188
|
+
const { children, fallback, variant = "card" } = this.props;
|
|
189
|
+
|
|
190
|
+
if (error) {
|
|
191
|
+
if (typeof fallback === "function") {
|
|
192
|
+
return fallback({ error, reset: this.reset });
|
|
193
|
+
}
|
|
194
|
+
if (fallback !== undefined) {
|
|
195
|
+
return fallback;
|
|
196
|
+
}
|
|
197
|
+
return <DefaultFallback error={error} reset={this.reset} variant={variant} />;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return <ErrorThrower>{children}</ErrorThrower>;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useCallback, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// CONTEXT
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
export interface ErrorBoundaryContextValue {
|
|
10
|
+
throwError: (error: Error) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ErrorBoundaryContext = createContext<ErrorBoundaryContextValue | null>(null);
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// HOOK
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook that provides a `throwError` function to trigger the nearest
|
|
21
|
+
* GliaErrorBoundary from a function component.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* const { throwError } = useErrorBoundary();
|
|
26
|
+
* stream.on('error', (err) => throwError(err));
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function useErrorBoundary(): ErrorBoundaryContextValue {
|
|
30
|
+
const ctx = useContext(ErrorBoundaryContext);
|
|
31
|
+
if (!ctx) {
|
|
32
|
+
throw new Error("useErrorBoundary must be used within a GliaErrorBoundary");
|
|
33
|
+
}
|
|
34
|
+
return ctx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// ERROR THROWER (internal helper)
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Internal component that catches programmatic errors thrown via context
|
|
43
|
+
* and re-throws them during render so React's error boundary catches them.
|
|
44
|
+
*/
|
|
45
|
+
export function ErrorThrower({ children }: { children: ReactNode }) {
|
|
46
|
+
const [error, setError] = useState<Error | null>(null);
|
|
47
|
+
|
|
48
|
+
if (error) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const throwError = useCallback((err: Error) => {
|
|
53
|
+
setError(err);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<ErrorBoundaryContext.Provider value={{ throwError }}>
|
|
58
|
+
{children}
|
|
59
|
+
</ErrorBoundaryContext.Provider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @backbay/glia Desktop OS - Desktop Component
|
|
3
|
+
*
|
|
4
|
+
* Main desktop surface that renders icons and provides window snap zone previews.
|
|
5
|
+
* Uses CSS variables for theming (--glia-color-*, --glia-spacing-*, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useCallback, useState } from 'react';
|
|
9
|
+
import type { DesktopIcon as DesktopIconType } from '../../core/desktop/types';
|
|
10
|
+
import { useSnapZones } from '../../core/desktop/useSnapZones';
|
|
11
|
+
import { DesktopIcon } from './DesktopIcon';
|
|
12
|
+
import { SnapZoneOverlay } from './SnapZoneOverlay';
|
|
13
|
+
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// Styles (using CSS custom properties)
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
const containerStyles: React.CSSProperties = {
|
|
19
|
+
position: 'fixed',
|
|
20
|
+
inset: 0,
|
|
21
|
+
display: 'flex',
|
|
22
|
+
flexDirection: 'column',
|
|
23
|
+
background: 'var(--glia-color-bg-body, #010100)',
|
|
24
|
+
overflow: 'hidden',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const desktopAreaStyles: React.CSSProperties = {
|
|
28
|
+
flex: 1,
|
|
29
|
+
position: 'relative',
|
|
30
|
+
padding: '16px',
|
|
31
|
+
paddingBottom: 'calc(16px + var(--glia-spacing-taskbar-height, 48px))',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const iconGridStyles: React.CSSProperties = {
|
|
35
|
+
display: 'grid',
|
|
36
|
+
gridTemplateColumns: 'repeat(auto-fill, var(--glia-spacing-icon-size, 80px))',
|
|
37
|
+
gridAutoRows: 'var(--glia-spacing-icon-size, 80px)',
|
|
38
|
+
gap: 'var(--glia-spacing-icon-gap, 16px)',
|
|
39
|
+
padding: '8px',
|
|
40
|
+
alignContent: 'start',
|
|
41
|
+
height: '100%',
|
|
42
|
+
overflow: 'auto',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
// Types
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
export interface DesktopProps {
|
|
50
|
+
/** Windows render here */
|
|
51
|
+
children?: React.ReactNode;
|
|
52
|
+
/** Desktop icons to display */
|
|
53
|
+
icons?: DesktopIconType[];
|
|
54
|
+
/** Callback when an icon is activated (double-click) */
|
|
55
|
+
onIconActivate?: (iconId: string) => void;
|
|
56
|
+
/** Whether to show snap zone overlays during window dragging */
|
|
57
|
+
showSnapZones?: boolean;
|
|
58
|
+
/** Additional CSS class */
|
|
59
|
+
className?: string;
|
|
60
|
+
/** Inline styles */
|
|
61
|
+
style?: React.CSSProperties;
|
|
62
|
+
/** Icon renderer - allows custom icon component */
|
|
63
|
+
renderIcon?: (icon: DesktopIconType, props: {
|
|
64
|
+
selected: boolean;
|
|
65
|
+
onClick: (e: React.MouseEvent) => void;
|
|
66
|
+
onDoubleClick: () => void;
|
|
67
|
+
}) => React.ReactNode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
71
|
+
// Component
|
|
72
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Desktop surface component.
|
|
76
|
+
*
|
|
77
|
+
* Renders the main desktop area with:
|
|
78
|
+
* - Icon grid for desktop shortcuts
|
|
79
|
+
* - Snap zone overlays for window tiling
|
|
80
|
+
* - Children (windows) layered on top
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```tsx
|
|
84
|
+
* <Desktop
|
|
85
|
+
* icons={myIcons}
|
|
86
|
+
* onIconActivate={(id) => launchApp(id)}
|
|
87
|
+
* showSnapZones={isDragging}
|
|
88
|
+
* >
|
|
89
|
+
* {windows.map(w => <Window key={w.id} {...w} />)}
|
|
90
|
+
* </Desktop>
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function Desktop({
|
|
94
|
+
children,
|
|
95
|
+
icons = [],
|
|
96
|
+
onIconActivate,
|
|
97
|
+
showSnapZones = true,
|
|
98
|
+
className,
|
|
99
|
+
style,
|
|
100
|
+
renderIcon,
|
|
101
|
+
}: DesktopProps) {
|
|
102
|
+
const { activeZone } = useSnapZones();
|
|
103
|
+
const [selectedIconIds, setSelectedIconIds] = useState<Set<string>>(new Set());
|
|
104
|
+
|
|
105
|
+
// Handle icon click (selection)
|
|
106
|
+
const handleIconClick = useCallback(
|
|
107
|
+
(iconId: string, e: React.MouseEvent) => {
|
|
108
|
+
setSelectedIconIds((prev) => {
|
|
109
|
+
const next = new Set(prev);
|
|
110
|
+
const additive = e.metaKey || e.ctrlKey;
|
|
111
|
+
|
|
112
|
+
if (additive) {
|
|
113
|
+
// Toggle selection
|
|
114
|
+
if (next.has(iconId)) {
|
|
115
|
+
next.delete(iconId);
|
|
116
|
+
} else {
|
|
117
|
+
next.add(iconId);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Replace selection
|
|
121
|
+
next.clear();
|
|
122
|
+
next.add(iconId);
|
|
123
|
+
}
|
|
124
|
+
return next;
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
[]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Handle icon double-click (activation)
|
|
131
|
+
const handleIconDoubleClick = useCallback(
|
|
132
|
+
(iconId: string) => {
|
|
133
|
+
onIconActivate?.(iconId);
|
|
134
|
+
},
|
|
135
|
+
[onIconActivate]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Clear selection when clicking desktop background
|
|
139
|
+
const handleBackgroundClick = useCallback(
|
|
140
|
+
(e: React.MouseEvent) => {
|
|
141
|
+
// Only clear if clicking the desktop area itself, not children
|
|
142
|
+
if (e.target === e.currentTarget) {
|
|
143
|
+
setSelectedIconIds(new Set());
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
className={className}
|
|
152
|
+
style={{ ...containerStyles, ...style }}
|
|
153
|
+
data-bb-desktop
|
|
154
|
+
>
|
|
155
|
+
{/* Desktop area with icons */}
|
|
156
|
+
<div
|
|
157
|
+
style={desktopAreaStyles}
|
|
158
|
+
onClick={handleBackgroundClick}
|
|
159
|
+
data-bb-desktop-area
|
|
160
|
+
>
|
|
161
|
+
{/* Icon grid */}
|
|
162
|
+
{icons.length > 0 && (
|
|
163
|
+
<div style={iconGridStyles} data-bb-icon-grid>
|
|
164
|
+
{icons.map((icon) => {
|
|
165
|
+
const isSelected = selectedIconIds.has(icon.id);
|
|
166
|
+
const clickHandler = (e: React.MouseEvent) => handleIconClick(icon.id, e);
|
|
167
|
+
const doubleClickHandler = () => handleIconDoubleClick(icon.id);
|
|
168
|
+
|
|
169
|
+
if (renderIcon) {
|
|
170
|
+
return (
|
|
171
|
+
<React.Fragment key={icon.id}>
|
|
172
|
+
{renderIcon(icon, {
|
|
173
|
+
selected: isSelected,
|
|
174
|
+
onClick: clickHandler,
|
|
175
|
+
onDoubleClick: doubleClickHandler,
|
|
176
|
+
})}
|
|
177
|
+
</React.Fragment>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<DesktopIcon
|
|
183
|
+
key={icon.id}
|
|
184
|
+
id={icon.id}
|
|
185
|
+
icon={icon.icon}
|
|
186
|
+
label={icon.label}
|
|
187
|
+
selected={isSelected}
|
|
188
|
+
onClick={clickHandler}
|
|
189
|
+
onDoubleClick={doubleClickHandler}
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Windows render on top */}
|
|
197
|
+
{children}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Snap zone overlay (visible during window drag) */}
|
|
201
|
+
{showSnapZones && activeZone && <SnapZoneOverlay />}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|