1<script lang="ts">
2 /**
3 * Generic Windows XP window shell driven by the desktop window manager.
4 * Handles: drag, optional resize, focus/z-index, minimize/close buttons,
5 * mobile full-screen fallback. Content goes in the default snippet.
6 */
7 import type { Snippet } from 'svelte';
8 import { onMount } from 'svelte';
9 import { draggable } from '$lib/actions/draggable';
10 import { resizable } from '$lib/actions/resizable';
11 import { desktop } from '$lib/states/desktop.svelte';
12
13 let {
14 id,
15 width = 420,
16 height = 320,
17 minWidth = 240,
18 minHeight = 160,
19 x,
20 y,
21 resizableWindow = true,
22 showMaximize = false,
23 bodyClass = '',
24 children,
25 menu,
26 onClose
27 } = $props<{
28 id: string;
29 width?: number;
30 height?: number;
31 minWidth?: number;
32 minHeight?: number;
33 x?: number;
34 y?: number;
35 resizableWindow?: boolean;
36 showMaximize?: boolean;
37 bodyClass?: string;
38 children: Snippet;
39 menu?: Snippet;
40 onClose?: () => void;
41 }>();
42
43 const win = $derived(desktop.get(id));
44 const focused = $derived(desktop.focusedId === id);
45
46 // svelte-ignore state_referenced_locally
47 let w = $state(width);
48 // svelte-ignore state_referenced_locally
49 let h = $state(height);
50 // svelte-ignore state_referenced_locally
51 let left = $state(x ?? 0);
52 // svelte-ignore state_referenced_locally
53 let top = $state(y ?? 0);
54 let isMobile = $state(false);
55 let isMaximized = $state(false);
56 let restore: { w: number; h: number; left: number; top: number } | null = null;
57
58 onMount(() => {
59 isMobile = window.innerWidth <= 768;
60 if (x === undefined || y === undefined) {
61 // Cascade new windows slightly from the centre so they do not stack exactly
62 const offset = (desktop.windows.length % 6) * 24;
63 left = Math.max(8, Math.round((window.innerWidth - w) / 2) + offset);
64 top = Math.max(8, Math.round((window.innerHeight - h) / 2 - 40) + offset);
65 }
66 const onResize = () => {
67 isMobile = window.innerWidth <= 768;
68 };
69 window.addEventListener('resize', onResize);
70 return () => window.removeEventListener('resize', onResize);
71 });
72
73 // Follow explicit size changes from the parent (e.g. a dialog switching tabs)
74 $effect(() => {
75 if (isMaximized) return;
76 w = width;
77 h = height;
78 });
79
80 function close() {
81 onClose?.();
82 desktop.close(id);
83 }
84
85 function minimize(e: MouseEvent) {
86 e.stopPropagation();
87 desktop.minimize(id);
88 }
89
90 function toggleMaximize(e?: MouseEvent) {
91 e?.stopPropagation();
92 if (isMobile) return;
93 if (isMaximized && restore) {
94 w = restore.w;
95 h = restore.h;
96 left = restore.left;
97 top = restore.top;
98 isMaximized = false;
99 } else {
100 restore = { w, h, left, top };
101 left = 4;
102 top = 4;
103 w = window.innerWidth - 8;
104 h = window.innerHeight - 8 - 30; // leave room for the taskbar
105 isMaximized = true;
106 }
107 }
108</script>
109
110{#if win}
111 <div
112 class="window xp-window"
113 class:inactive={!focused}
114 class:hidden={win.minimized}
115 class:mobile={isMobile}
116 style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};"
117 role="dialog"
118 aria-label={win.title}
119 tabindex="-1"
120 onmousedown={() => desktop.focus(id)}
121 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
122 ondragmove={(e) => {
123 left = e.detail.x;
124 top = e.detail.y;
125 }}
126 use:resizable={{
127 enabled: resizableWindow && !isMobile && !isMaximized,
128 minWidth,
129 minHeight,
130 maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600,
131 maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200
132 }}
133 onresizemove={(e) => {
134 w = Math.max(minWidth, e.detail.width);
135 h = Math.max(minHeight, e.detail.height);
136 }}
137 >
138 <div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation">
139 <div class="title-bar-text">
140 <img src={win.icon} alt="" width="16" height="16" class="title-icon" />
141 <span class="title-text">{win.title}</span>
142 </div>
143 <div class="title-bar-controls">
144 <button aria-label="Minimize" onclick={minimize}></button>
145 {#if showMaximize && !isMobile}
146 <button aria-label={isMaximized ? 'Restore' : 'Maximize'} onclick={toggleMaximize}
147 ></button>
148 {/if}
149 <button aria-label="Close" onclick={close}></button>
150 </div>
151 </div>
152 {#if menu}
153 <div class="xp-menubar">{@render menu()}</div>
154 {/if}
155 <div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}>
156 {@render children()}
157 </div>
158 </div>
159{/if}
160
161<style>
162 .xp-window {
163 position: fixed;
164 box-sizing: border-box;
165 display: flex;
166 flex-direction: column;
167 font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif;
168 box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35);
169 }
170
171 .xp-window.hidden {
172 display: none;
173 }
174
175 .xp-window.inactive {
176 box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
177 }
178
179 /* xp.css already styles active/inactive gradients through :not(:focus-within);
180 force the inactive look based on our own focus tracking instead. */
181 .xp-window.inactive .title-bar {
182 background: linear-gradient(
183 180deg,
184 #7697e7 0%,
185 #7e9ee3 8%,
186 #94afec 40%,
187 #97b4e9 88%,
188 #82a5e4 100%
189 );
190 }
191
192 .title-bar {
193 cursor: move;
194 user-select: none;
195 display: flex;
196 align-items: center;
197 flex: 0 0 auto;
198 height: 30px;
199 box-sizing: border-box;
200 padding: 0 5px 0 4px;
201 font-size: 13px;
202 }
203
204 .title-bar-text {
205 display: flex;
206 align-items: center;
207 gap: 5px;
208 white-space: nowrap;
209 overflow: hidden;
210 text-overflow: ellipsis;
211 min-width: 0;
212 flex: 1;
213 margin-right: 8px;
214 }
215
216 .title-bar-controls {
217 display: flex;
218 align-items: center;
219 gap: 2px;
220 flex: 0 0 auto;
221 }
222
223 .title-bar-controls button {
224 width: 21px;
225 height: 21px;
226 min-width: 21px;
227 min-height: 21px;
228 margin: 0;
229 flex: 0 0 21px;
230 }
231
232 .title-text {
233 overflow: hidden;
234 text-overflow: ellipsis;
235 }
236
237 .title-icon {
238 flex: 0 0 16px;
239 }
240
241 .xp-menubar {
242 display: flex;
243 gap: 2px;
244 padding: 1px 4px;
245 background: #ece9d8;
246 border-bottom: 1px solid #aca899;
247 font-size: 11px;
248 flex: 0 0 auto;
249 }
250
251 .xp-menubar :global(button) {
252 border: 1px solid transparent;
253 background: transparent;
254 box-shadow: none;
255 min-width: 0;
256 padding: 2px 6px;
257 font-size: 11px;
258 border-radius: 0;
259 }
260
261 .xp-menubar :global(button:hover) {
262 background: #316ac5;
263 color: white;
264 }
265
266 .xp-body {
267 flex: 1 1 auto;
268 min-height: 0;
269 display: flex;
270 flex-direction: column;
271 margin: 0;
272 padding: 6px;
273 overflow: hidden;
274 }
275
276 :global(.xp-window .resize-handle) {
277 position: absolute;
278 bottom: 0;
279 right: 0;
280 width: 15px !important;
281 height: 15px !important;
282 cursor: se-resize !important;
283 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23555'/%3E%3C/svg%3E");
284 background-position: bottom right;
285 background-repeat: no-repeat;
286 }
287
288 @media (max-width: 768px) {
289 .xp-window.mobile {
290 left: 0 !important;
291 top: 0 !important;
292 width: 100vw !important;
293 height: calc(100vh - 30px) !important;
294 border-radius: 0;
295 }
296 }
297</style>