@deseteral/biurko 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,841 @@
1
+ // src/types.ts
2
+ const RESIZE_DIRECTIONS = [
3
+ "n",
4
+ "s",
5
+ "e",
6
+ "w",
7
+ "ne",
8
+ "nw",
9
+ "se",
10
+ "sw"
11
+ ];
12
+ // src/drag-state.ts
13
+ function createDragState(wm) {
14
+ let activeHandle = null;
15
+ let offsetX = 0;
16
+ let offsetY = 0;
17
+ function onMouseMove(e) {
18
+ if (!activeHandle) return;
19
+ const pointer = wm.clientToWorld(e.clientX, e.clientY);
20
+ wm.moveWindow(activeHandle, pointer.x - offsetX, pointer.y - offsetY);
21
+ }
22
+ function onMouseUp() {
23
+ if (activeHandle) {
24
+ const rect = wm.getWindowRect(activeHandle);
25
+ if (rect) wm.dispatchEvent(new CustomEvent("window-moved", { detail: {
26
+ handle: activeHandle,
27
+ x: rect.x,
28
+ y: rect.y
29
+ } }));
30
+ }
31
+ activeHandle = null;
32
+ document.removeEventListener("mousemove", onMouseMove);
33
+ document.removeEventListener("mouseup", onMouseUp);
34
+ }
35
+ return {
36
+ start(handle, clientX, clientY) {
37
+ const rect = wm.getWindowRect(handle);
38
+ if (!rect) return;
39
+ const origin = wm.clientToWorld(clientX, clientY);
40
+ if (wm.getWindowParentOptions(handle).detachOnDrag) wm.detachWindowFromParent(handle);
41
+ activeHandle = handle;
42
+ offsetX = origin.x - rect.x;
43
+ offsetY = origin.y - rect.y;
44
+ document.addEventListener("mousemove", onMouseMove);
45
+ document.addEventListener("mouseup", onMouseUp);
46
+ },
47
+ isActive() {
48
+ return activeHandle !== null;
49
+ }
50
+ };
51
+ }
52
+ // src/resize-state.ts
53
+ function createResizeState(wm) {
54
+ let activeHandle = null;
55
+ let activeDirection = null;
56
+ let startX = 0;
57
+ let startY = 0;
58
+ let startRect = {
59
+ x: 0,
60
+ y: 0,
61
+ width: 0,
62
+ height: 0
63
+ };
64
+ let minWidth = 0;
65
+ let minHeight = 0;
66
+ function start(handle, direction, clientX, clientY) {
67
+ const rect = wm.getWindowRect(handle);
68
+ if (!rect) return;
69
+ const minSize = wm.getMinSize(handle);
70
+ if (!minSize) return;
71
+ activeHandle = handle;
72
+ activeDirection = direction;
73
+ startX = clientX;
74
+ startY = clientY;
75
+ startRect = { ...rect };
76
+ minWidth = minSize.minWidth;
77
+ minHeight = minSize.minHeight;
78
+ wm.getDesktopElement().style.userSelect = "none";
79
+ document.addEventListener("mousemove", onMouseMove);
80
+ document.addEventListener("mouseup", onMouseUp);
81
+ }
82
+ function onMouseMove(e) {
83
+ if (!activeHandle || !activeDirection) return;
84
+ const dx = (e.clientX - startX) / wm.getZoomLevel();
85
+ const dy = (e.clientY - startY) / wm.getZoomLevel();
86
+ let newX = startRect.x;
87
+ let newY = startRect.y;
88
+ let newWidth = startRect.width;
89
+ let newHeight = startRect.height;
90
+ if (activeDirection.includes("e")) newWidth = Math.max(minWidth, startRect.width + dx);
91
+ else if (activeDirection.includes("w")) {
92
+ const proposedWidth = startRect.width - dx;
93
+ if (proposedWidth >= minWidth) {
94
+ newWidth = proposedWidth;
95
+ newX = startRect.x + dx;
96
+ } else {
97
+ newWidth = minWidth;
98
+ newX = startRect.x + (startRect.width - minWidth);
99
+ }
100
+ }
101
+ if (activeDirection.includes("s")) newHeight = Math.max(minHeight, startRect.height + dy);
102
+ else if (activeDirection.includes("n")) {
103
+ const proposedHeight = startRect.height - dy;
104
+ if (proposedHeight >= minHeight) {
105
+ newHeight = proposedHeight;
106
+ newY = startRect.y + dy;
107
+ } else {
108
+ newHeight = minHeight;
109
+ newY = startRect.y + (startRect.height - minHeight);
110
+ }
111
+ }
112
+ wm.resizeWindow(activeHandle, newX, newY, newWidth, newHeight);
113
+ }
114
+ function onMouseUp() {
115
+ if (activeHandle) {
116
+ const rect = wm.getWindowRect(activeHandle);
117
+ if (rect) wm.dispatchEvent(new CustomEvent("window-resized", { detail: {
118
+ handle: activeHandle,
119
+ x: rect.x,
120
+ y: rect.y,
121
+ width: rect.width,
122
+ height: rect.height
123
+ } }));
124
+ }
125
+ activeHandle = null;
126
+ activeDirection = null;
127
+ wm.getDesktopElement().style.userSelect = "";
128
+ document.removeEventListener("mousemove", onMouseMove);
129
+ document.removeEventListener("mouseup", onMouseUp);
130
+ }
131
+ return {
132
+ start,
133
+ isActive() {
134
+ return activeHandle !== null && activeDirection !== null;
135
+ }
136
+ };
137
+ }
138
+ // src/resize-region-styling.ts
139
+ function applyResizeRegionStyles(el, direction, regionSize) {
140
+ el.style.position = "absolute";
141
+ el.style.zIndex = "1";
142
+ el.style.cursor = `${direction}-resize`;
143
+ const size = regionSize * 2;
144
+ const offset = `-${regionSize}px`;
145
+ const cornerSize = `${size}px`;
146
+ const origin = `${regionSize}px`;
147
+ switch (direction) {
148
+ case "n":
149
+ el.style.top = offset;
150
+ el.style.left = origin;
151
+ el.style.right = origin;
152
+ el.style.height = `${size}px`;
153
+ break;
154
+ case "s":
155
+ el.style.bottom = offset;
156
+ el.style.left = origin;
157
+ el.style.right = origin;
158
+ el.style.height = `${size}px`;
159
+ break;
160
+ case "e":
161
+ el.style.right = offset;
162
+ el.style.top = origin;
163
+ el.style.bottom = origin;
164
+ el.style.width = `${size}px`;
165
+ break;
166
+ case "w":
167
+ el.style.left = offset;
168
+ el.style.top = origin;
169
+ el.style.bottom = origin;
170
+ el.style.width = `${size}px`;
171
+ break;
172
+ case "ne":
173
+ el.style.top = offset;
174
+ el.style.right = offset;
175
+ el.style.width = cornerSize;
176
+ el.style.height = cornerSize;
177
+ break;
178
+ case "nw":
179
+ el.style.top = offset;
180
+ el.style.left = offset;
181
+ el.style.width = cornerSize;
182
+ el.style.height = cornerSize;
183
+ break;
184
+ case "se":
185
+ el.style.bottom = offset;
186
+ el.style.right = offset;
187
+ el.style.width = cornerSize;
188
+ el.style.height = cornerSize;
189
+ break;
190
+ case "sw":
191
+ el.style.bottom = offset;
192
+ el.style.left = offset;
193
+ el.style.width = cornerSize;
194
+ el.style.height = cornerSize;
195
+ }
196
+ }
197
+ // src/window-manager.ts
198
+ const DEFAULT_RESIZE_REGION_SIZE = 6;
199
+ const WORLD_SIZE_PX = 2e5;
200
+ const WORLD_ORIGIN_OFFSET = WORLD_SIZE_PX / 2;
201
+ const DEFAULT_ZOOM_LEVEL = 1;
202
+ const MIN_ZOOM_LEVEL = .1;
203
+ const DRAG_REGION_SELECTOR = "[data-biurko-drag-region]";
204
+ /**
205
+ * Core window management class that handles window lifecycle and actions such as drag, resize, focus, and ordering.
206
+ *
207
+ * Dispatches typed events (see {@link WindowManagerEventMap}).
208
+ *
209
+ * @typeParam AttachedDataT - Optional data type attached to each window. Use `void` (default) if no
210
+ * data is needed.
211
+ */
212
+ var WindowManager = class extends EventTarget {
213
+ desktopElement;
214
+ scrollerElement;
215
+ worldElement;
216
+ windows = /* @__PURE__ */ new Map();
217
+ dragState;
218
+ resizeState;
219
+ resizeRegionSize;
220
+ positionStrategy;
221
+ mode;
222
+ windowInteractivity;
223
+ translateX = 0;
224
+ translateY = 0;
225
+ zoomLevel = DEFAULT_ZOOM_LEVEL;
226
+ /**
227
+ * Creates a new WindowManager bound to the given container element.
228
+ * The container acts as the "desktop" area where windows are positioned. Its `data-biurko-type`
229
+ * attribute is set to `"desktop"`, its position is set to `relative`, and the `biurko-desktop`
230
+ * CSS class is added automatically.
231
+ *
232
+ * Windows are placed inside an inner layer with `data-biurko-type="world"`.
233
+ * In `"infinite-canvas"` mode, scrolling the desktop background pans the canvas and
234
+ * Shift+wheel zooms around the pointer. Holding Alt also enables these gestures over windows.
235
+ * Panning is also available through {@link setViewportTranslate}.
236
+ *
237
+ * @param container - The DOM element that serves as the desktop area.
238
+ * @param options - Optional configuration details.
239
+ */
240
+ constructor(container, options) {
241
+ super();
242
+ this.desktopElement = container;
243
+ this.desktopElement.classList.add("biurko-desktop");
244
+ this.desktopElement.dataset["biurkoType"] = "desktop";
245
+ this.mode = options?.mode ?? { type: "static" };
246
+ this.windowInteractivity = options?.windowInteractivity ?? { type: "all" };
247
+ this.resizeRegionSize = options?.resizeRegionSize ?? DEFAULT_RESIZE_REGION_SIZE;
248
+ this.positionStrategy = options?.positionStrategy ?? { type: "required" };
249
+ this.desktopElement.style.position = "relative";
250
+ const scrollerElement = document.createElement("div");
251
+ scrollerElement.dataset["biurkoType"] = "scroller";
252
+ scrollerElement.style.position = "absolute";
253
+ scrollerElement.style.top = "0px";
254
+ scrollerElement.style.left = "0px";
255
+ scrollerElement.style.width = "100%";
256
+ scrollerElement.style.height = "100%";
257
+ scrollerElement.style.overflow = "hidden";
258
+ if (this.mode.type === "infinite-canvas") {
259
+ scrollerElement.style.overscrollBehavior = "none";
260
+ scrollerElement.addEventListener("wheel", (e) => this.onWheel(e), { passive: false });
261
+ }
262
+ const worldElement = document.createElement("div");
263
+ worldElement.dataset["biurkoType"] = "world";
264
+ worldElement.style.position = "absolute";
265
+ worldElement.style.top = "0px";
266
+ worldElement.style.left = "0px";
267
+ if (this.mode.type === "infinite-canvas") {
268
+ worldElement.style.width = `${WORLD_SIZE_PX}px`;
269
+ worldElement.style.height = `${WORLD_SIZE_PX}px`;
270
+ worldElement.style.transformOrigin = "center center";
271
+ } else {
272
+ worldElement.style.width = "100%";
273
+ worldElement.style.height = "100%";
274
+ }
275
+ scrollerElement.appendChild(worldElement);
276
+ this.desktopElement.appendChild(scrollerElement);
277
+ this.scrollerElement = scrollerElement;
278
+ this.worldElement = worldElement;
279
+ if (this.mode.type === "infinite-canvas") {
280
+ this.scrollerElement.scrollLeft = WORLD_ORIGIN_OFFSET;
281
+ this.scrollerElement.scrollTop = WORLD_ORIGIN_OFFSET;
282
+ }
283
+ this.dragState = createDragState(this);
284
+ this.resizeState = createResizeState(this);
285
+ const sheet = new CSSStyleSheet();
286
+ sheet.replaceSync(`${DRAG_REGION_SELECTOR} { pointer-events: all; }`);
287
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
288
+ }
289
+ /**
290
+ * Creates a new window and appends it to the desktop's world layer.
291
+ * The window is automatically focused (brought to front) upon creation.
292
+ * Dispatches `"window-focused"` before `"window-opened"`.
293
+ * Initial position and size are not clamped to desktop bounds or minimum size constraints.
294
+ *
295
+ * @param options - Configuration details (see {@link CreateWindowOptions}).
296
+ * @param args - If `AttachedDataT` is not `void`, the attached data must be passed as the second argument.
297
+ * @returns A unique {@link WindowHandle} identifying the created window.
298
+ * @throws Error if either coordinate is missing when the position strategy is `"required"`.
299
+ */
300
+ createWindow(options, ...args) {
301
+ const handle = crypto.randomUUID();
302
+ const data = args[0];
303
+ const position = this.resolveInitialPosition(options.x, options.y);
304
+ const element = document.createElement("div");
305
+ element.dataset["windowHandle"] = handle;
306
+ element.dataset["biurkoType"] = "window";
307
+ element.style.position = "absolute";
308
+ const originOffset = this.worldOriginOffset;
309
+ element.style.left = `${position.x + originOffset}px`;
310
+ element.style.top = `${position.y + originOffset}px`;
311
+ element.style.width = `${options.width}px`;
312
+ element.style.height = `${options.height}px`;
313
+ const surface = document.createElement("div");
314
+ surface.classList.add("biurko-surface");
315
+ surface.style.height = "100%";
316
+ surface.style.overflow = "hidden";
317
+ if (this.windowInteractivity.type === "focused-only") surface.style.pointerEvents = "none";
318
+ if (this.mode.type === "infinite-canvas") surface.style.overscrollBehavior = "contain";
319
+ element.appendChild(surface);
320
+ for (const direction of RESIZE_DIRECTIONS) {
321
+ const resizeRegion = document.createElement("div");
322
+ resizeRegion.dataset["resizeDirection"] = direction;
323
+ applyResizeRegionStyles(resizeRegion, direction, this.resizeRegionSize);
324
+ resizeRegion.addEventListener("mousedown", (e) => {
325
+ e.stopPropagation();
326
+ this.resizeState.start(handle, direction, e.clientX, e.clientY);
327
+ this.focusWindow(handle);
328
+ });
329
+ element.appendChild(resizeRegion);
330
+ }
331
+ this.worldElement.appendChild(element);
332
+ const state = {
333
+ handle,
334
+ element,
335
+ surface,
336
+ title: options.title,
337
+ data,
338
+ minWidth: options.minWidth ?? 0,
339
+ minHeight: options.minHeight ?? 0,
340
+ orderIdx: this.windows.size,
341
+ parentWindow: null
342
+ };
343
+ this.windows.set(handle, state);
344
+ element.addEventListener("mousedown", (e) => {
345
+ if (e.target?.closest(DRAG_REGION_SELECTOR)) this.dragState.start(handle, e.clientX, e.clientY);
346
+ this.focusWindow(handle);
347
+ });
348
+ this.focusWindow(handle);
349
+ this.dispatchEvent(new CustomEvent("window-opened", { detail: { handle } }));
350
+ return handle;
351
+ }
352
+ /**
353
+ * Brings the specified window to front.
354
+ * No-op if the handle is invalid.
355
+ * Dispatches a `"window-focused"` event even if the window is already focused.
356
+ *
357
+ * @param handle - The handle of the window to focus.
358
+ */
359
+ focusWindow(handle) {
360
+ const target = this.windows.get(handle);
361
+ if (!target) return;
362
+ for (const state of this.windows.values()) {
363
+ if (state.handle === handle) continue;
364
+ if (this.windowInteractivity.type === "focused-only" && state.orderIdx === 0) state.surface.style.pointerEvents = "none";
365
+ if (state.orderIdx < target.orderIdx) state.orderIdx += 1;
366
+ }
367
+ target.surface.style.pointerEvents = "all";
368
+ target.orderIdx = 0;
369
+ this.applyWindowOrdering();
370
+ this.dispatchEvent(new CustomEvent("window-focused", { detail: { handle } }));
371
+ }
372
+ /**
373
+ * Returns the handle of the currently focused window.
374
+ *
375
+ * @returns The {@link WindowHandle} of the focused window, or `null` if no windows are open.
376
+ */
377
+ getFocusedWindow() {
378
+ for (const state of this.windows.values()) if (state.orderIdx === 0) return state.handle;
379
+ return null;
380
+ }
381
+ /**
382
+ * Closes and removes the window together with its complete descendant subtree.
383
+ * The windows' DOM elements are removed and their internal state is cleaned up.
384
+ * One `"window-closed"` event is dispatched per removed window, descendants first.
385
+ * No-op if the handle is invalid or its window is no longer open.
386
+ *
387
+ * @param handle - The handle of the window to close.
388
+ */
389
+ closeWindow(handle) {
390
+ if (!this.windows.get(handle)) return;
391
+ const subtree = this.collectWindowSubtree(handle).reverse();
392
+ for (const state of subtree) {
393
+ state.element.remove();
394
+ this.windows.delete(state.handle);
395
+ }
396
+ this.applyWindowOrdering();
397
+ const nextFocusedWindow = this.getFocusedWindow();
398
+ if (nextFocusedWindow) this.focusWindow(nextFocusedWindow);
399
+ for (const state of subtree) this.dispatchEvent(new CustomEvent("window-closed", { detail: { handle: state.handle } }));
400
+ }
401
+ /**
402
+ * Attaches a window to another window, forming a parent-child relationship.
403
+ * Moving the parent moves the complete child subtree (see {@link moveWindow}),
404
+ * and closing the parent closes the complete child subtree (see {@link closeWindow}).
405
+ * Attachment does not affect position, size, focus, z-order, or DOM structure.
406
+ *
407
+ * Attaching a window to itself or to one of its descendants is rejected.
408
+ * Attaching a window that already has a parent reparents it with the given options.
409
+ * No-op if either handle is invalid or the relationship already exists.
410
+ * Dispatches a `"window-parent-changed"` event on success.
411
+ *
412
+ * @param child - The handle of the window to attach.
413
+ * @param parent - The handle of the window to attach to.
414
+ * @param options - Configuration of the relationship (see {@link WindowParentOptions}).
415
+ * To change the options of an existing relationship, detach the window and attach it again.
416
+ */
417
+ attachWindowToParent(child, parent, options) {
418
+ const childState = this.windows.get(child);
419
+ if (!childState || !this.windows.has(parent)) return;
420
+ if (child === parent) return;
421
+ if (childState.parentWindow?.handle === parent) return;
422
+ let ancestor = parent;
423
+ while (ancestor !== null) {
424
+ if (ancestor === child) return;
425
+ ancestor = this.windows.get(ancestor)?.parentWindow?.handle ?? null;
426
+ }
427
+ const previousParent = childState.parentWindow?.handle ?? null;
428
+ childState.parentWindow = {
429
+ handle: parent,
430
+ detachOnDrag: options?.detachOnDrag ?? false
431
+ };
432
+ this.dispatchEvent(new CustomEvent("window-parent-changed", { detail: {
433
+ handle: child,
434
+ previousParent,
435
+ parent
436
+ } }));
437
+ }
438
+ /**
439
+ * Detaches a window from its parent, turning the window together with its
440
+ * descendants into an independent root tree. Descendant relationships are preserved.
441
+ * Relationship options are reset to defaults.
442
+ * No-op if the handle is invalid or the window has no parent.
443
+ * Dispatches a `"window-parent-changed"` event on success.
444
+ *
445
+ * @param child - The handle of the window to detach.
446
+ */
447
+ detachWindowFromParent(child) {
448
+ const childState = this.windows.get(child);
449
+ if (!childState || childState.parentWindow === null) return;
450
+ const previousParent = childState.parentWindow?.handle ?? null;
451
+ childState.parentWindow = null;
452
+ this.dispatchEvent(new CustomEvent("window-parent-changed", { detail: {
453
+ handle: child,
454
+ previousParent,
455
+ parent: null
456
+ } }));
457
+ }
458
+ /**
459
+ * Returns the parent of the given window.
460
+ *
461
+ * @param child - The window handle.
462
+ * @returns The parent handle, or `null` if the window is a root or the handle is invalid.
463
+ */
464
+ getWindowParent(child) {
465
+ return this.windows.get(child)?.parentWindow?.handle ?? null;
466
+ }
467
+ /**
468
+ * Returns the direct children of the given window.
469
+ *
470
+ * @param parent - The window handle.
471
+ * @returns The handles of direct children in creation order. Empty if the window has no children.
472
+ */
473
+ getChildren(parent) {
474
+ const children = [];
475
+ for (const state of this.windows.values()) if (state.parentWindow?.handle === parent) children.push(state.handle);
476
+ return children;
477
+ }
478
+ /**
479
+ * Returns the options of the parent-child relationship of the given window
480
+ * (see {@link attachWindowToParent}).
481
+ *
482
+ * @param child - The window handle.
483
+ * @returns The relationship options with defaults applied. Defaults are returned
484
+ * if the window is a root or the handle is invalid.
485
+ */
486
+ getWindowParentOptions(child) {
487
+ return { detachOnDrag: this.windows.get(child)?.parentWindow?.detachOnDrag ?? false };
488
+ }
489
+ /**
490
+ * Returns the surface element (content container) for the given window.
491
+ * This is the `HTMLDivElement` where you should render your window content.
492
+ * It has the CSS class `biurko-surface`.
493
+ *
494
+ * @param handle - The window handle.
495
+ * @returns The surface element, or `null` if the handle is invalid.
496
+ */
497
+ getWindowSurface(handle) {
498
+ const windowState = this.windows.get(handle);
499
+ if (!windowState) return null;
500
+ return windowState.surface;
501
+ }
502
+ /**
503
+ * Returns the current title of the window.
504
+ *
505
+ * @param handle - The window handle.
506
+ * @returns The title string, or `null` if the handle is invalid.
507
+ */
508
+ getWindowTitle(handle) {
509
+ const windowState = this.windows.get(handle);
510
+ if (!windowState) return null;
511
+ return windowState.title;
512
+ }
513
+ /**
514
+ * Updates the title of the window.
515
+ * This only updates the internal state (see {@link getWindowTitle}).
516
+ *
517
+ * @param handle - The window handle.
518
+ * @param title - The new title string.
519
+ */
520
+ setWindowTitle(handle, title) {
521
+ const windowState = this.windows.get(handle);
522
+ if (!windowState) return;
523
+ windowState.title = title;
524
+ }
525
+ /**
526
+ * Returns the attached data for the given window.
527
+ * The data type is determined by the `AttachedDataT` generic parameter.
528
+ *
529
+ * @param handle - The window handle.
530
+ * @returns The attached data, or `null` if the handle is invalid or the stored data is falsy
531
+ * (including when no data was provided).
532
+ */
533
+ getWindowData(handle) {
534
+ const windowState = this.windows.get(handle);
535
+ if (!windowState) return null;
536
+ return windowState.data || null;
537
+ }
538
+ /**
539
+ * Returns the current position and dimensions of the window.
540
+ *
541
+ * @param handle - The window handle.
542
+ * @returns A {@link WindowRect}, or `null` if the handle is invalid.
543
+ */
544
+ getWindowRect(handle) {
545
+ const windowState = this.windows.get(handle);
546
+ if (!windowState) return null;
547
+ return this.getWindowRectInWorldCoords(windowState);
548
+ }
549
+ /**
550
+ * Returns the minimum size constraints for the window.
551
+ *
552
+ * @param handle - The window handle.
553
+ * @returns An object with `minWidth` and `minHeight`, or `null` if the handle is invalid.
554
+ */
555
+ getMinSize(handle) {
556
+ const windowState = this.windows.get(handle);
557
+ if (!windowState) return null;
558
+ return {
559
+ minWidth: windowState.minWidth,
560
+ minHeight: windowState.minHeight
561
+ };
562
+ }
563
+ /**
564
+ * Returns the container element containing windows that represents the desktop.
565
+ */
566
+ getDesktopElement() {
567
+ return this.desktopElement;
568
+ }
569
+ /** Offset between logical world coordinates and physical offsets inside the world layer. */
570
+ get worldOriginOffset() {
571
+ return this.mode.type === "infinite-canvas" ? WORLD_ORIGIN_OFFSET : 0;
572
+ }
573
+ /**
574
+ * Returns the stored viewport translation, initially `{ x: 0, y: 0 }`.
575
+ * This is the requested offset, which may differ from the actual scroll position
576
+ * when the browser clamps scrolling at the world bounds.
577
+ *
578
+ * @returns The horizontal and vertical translate offsets in pixels.
579
+ */
580
+ getViewportTranslate() {
581
+ return {
582
+ x: this.translateX,
583
+ y: this.translateY
584
+ };
585
+ }
586
+ /**
587
+ * Stores a viewport translation and updates the inner container's scroll position.
588
+ * Intended for `"infinite-canvas"` mode, where positive offsets pan the world right and down.
589
+ * The stored offsets are not clamped, but the browser limits the actual scroll position
590
+ * to the scrollable world bounds. Static mode does not ignore or reset these offsets.
591
+ * Dispatches a `"viewport-translated"` event when the stored offset changes.
592
+ *
593
+ * @param x - New horizontal offset in pixels.
594
+ * @param y - New vertical offset in pixels.
595
+ */
596
+ setViewportTranslate(x, y) {
597
+ if (x === this.translateX && y === this.translateY) return;
598
+ this.translateX = x;
599
+ this.translateY = y;
600
+ this.scrollerElement.scrollLeft = WORLD_ORIGIN_OFFSET - this.translateX;
601
+ this.scrollerElement.scrollTop = WORLD_ORIGIN_OFFSET - this.translateY;
602
+ this.dispatchEvent(new CustomEvent("viewport-translated", { detail: {
603
+ x,
604
+ y
605
+ } }));
606
+ }
607
+ /**
608
+ * Translates the world layer by a relative offset (see {@link setViewportTranslate}).
609
+ *
610
+ * @param dx - Horizontal offset delta in pixels.
611
+ * @param dy - Vertical offset delta in pixels.
612
+ */
613
+ translateViewport(dx, dy) {
614
+ this.setViewportTranslate(this.translateX + dx, this.translateY + dy);
615
+ }
616
+ /**
617
+ * Sets the scale of the world layer.
618
+ * Values below `0.1` are clamped to `0.1`; there is no maximum.
619
+ * Applies in both desktop modes and does not adjust the viewport translation.
620
+ *
621
+ * @param level - The scale applied to the world layer.
622
+ */
623
+ setZoomLevel(level) {
624
+ this.zoomLevel = Math.max(MIN_ZOOM_LEVEL, level);
625
+ this.worldElement.style.transform = `scale(${this.zoomLevel}) translateZ(0)`;
626
+ }
627
+ /**
628
+ * Adds `delta` to the current zoom level (see {@link setZoomLevel}).
629
+ * This is an additive change, not a multiplication of the scale.
630
+ *
631
+ * @param delta - The delta applied to the world layer scale.
632
+ */
633
+ zoomBy(delta) {
634
+ this.setZoomLevel(this.zoomLevel + delta);
635
+ }
636
+ /** Resets the world layer's zoom level to `1` without changing the viewport translation. */
637
+ resetZoomLevel() {
638
+ this.setZoomLevel(DEFAULT_ZOOM_LEVEL);
639
+ }
640
+ /**
641
+ * Returns the scale of the world layer.
642
+ *
643
+ * @returns The current zoom level, initially `1` and at least `0.1`.
644
+ */
645
+ getZoomLevel() {
646
+ return this.zoomLevel;
647
+ }
648
+ setZoomLevelAtPoint(level, clientX, clientY) {
649
+ const point = this.clientToWorld(clientX, clientY);
650
+ const previousZoomLevel = this.zoomLevel;
651
+ this.setZoomLevel(level);
652
+ const zoomDelta = this.zoomLevel - previousZoomLevel;
653
+ this.setViewportTranslate(this.translateX - zoomDelta * point.x, this.translateY - zoomDelta * point.y);
654
+ }
655
+ /**
656
+ * Converts viewport (client) coordinates to desktop world coordinates,
657
+ * taking the desktop's position, actual scroll offsets, and zoom level into account.
658
+ *
659
+ * @param clientX - Horizontal position in client (viewport) coordinates.
660
+ * @param clientY - Vertical position in client (viewport) coordinates.
661
+ * @returns The corresponding position in desktop coordinate space.
662
+ */
663
+ clientToWorld(clientX, clientY) {
664
+ const rect = this.scrollerElement.getBoundingClientRect();
665
+ const originOffset = this.worldOriginOffset;
666
+ return {
667
+ x: (clientX - rect.left + this.scrollerElement.scrollLeft - originOffset) / this.zoomLevel,
668
+ y: (clientY - rect.top + this.scrollerElement.scrollTop - originOffset) / this.zoomLevel
669
+ };
670
+ }
671
+ onWheel(e) {
672
+ if (this.dragState.isActive() || this.resizeState.isActive()) return;
673
+ const zoomMode = e.shiftKey;
674
+ if (!e.altKey) {
675
+ const type = e.target.dataset["biurkoType"];
676
+ if (type !== this.scrollerElement.dataset["biurkoType"] && type !== this.worldElement.dataset["biurkoType"]) return;
677
+ }
678
+ if (zoomMode) {
679
+ const factor = Math.exp(e.deltaY / 1e3);
680
+ this.setZoomLevelAtPoint(this.zoomLevel * factor, e.clientX, e.clientY);
681
+ } else this.translateViewport(-e.deltaX, -e.deltaY);
682
+ }
683
+ /**
684
+ * Moves the window to the specified position in desktop coordinate space.
685
+ * The window's complete descendant subtree (see {@link attachWindowToParent}) is moved
686
+ * by the same delta, preserving relative positions inside the subtree.
687
+ * In `"static"` mode only the moved root is clamped to keep a small portion visible
688
+ * within the desktop bounds; descendants follow rigidly and may overflow the bounds.
689
+ * In `"infinite-canvas"` mode positions are not clamped.
690
+ * No-op if the handle is invalid. Does not dispatch `"window-moved"`;
691
+ * that event is dispatched only when a drag gesture completes, for the dragged window.
692
+ *
693
+ * @param handle - The window handle.
694
+ * @param x - New horizontal position in pixels.
695
+ * @param y - New vertical position in pixels.
696
+ */
697
+ moveWindow(handle, x, y) {
698
+ const rootState = this.windows.get(handle);
699
+ if (!rootState) return;
700
+ const rootRect = this.getWindowRectInWorldCoords(rootState);
701
+ const target = this.clampWindowPositionWithinDesktop(x, y, rootRect.width);
702
+ const dx = target.x - rootRect.x;
703
+ const dy = target.y - rootRect.y;
704
+ const originOffset = this.worldOriginOffset;
705
+ for (const state of this.collectWindowSubtree(handle)) {
706
+ const rect = this.getWindowRectInWorldCoords(state);
707
+ state.element.style.left = `${rect.x + dx + originOffset}px`;
708
+ state.element.style.top = `${rect.y + dy + originOffset}px`;
709
+ }
710
+ }
711
+ /**
712
+ * Resizes and repositions the window.
713
+ * The size is clamped to the minimum constraints (`minWidth`/`minHeight`).
714
+ * In `"static"` mode the position is clamped to keep a small portion of the window visible;
715
+ * in `"infinite-canvas"` mode the position is not clamped.
716
+ * Descendant windows are not moved or resized.
717
+ * No-op if the handle is invalid. Does not dispatch `"window-resized"`;
718
+ * that event is dispatched only when a resize gesture completes.
719
+ *
720
+ * @param handle - The window handle.
721
+ * @param x - New horizontal position in pixels in desktop coordinate space.
722
+ * @param y - New vertical position in pixels in desktop coordinate space.
723
+ * @param width - New width in pixels (will be clamped to `minWidth`).
724
+ * @param height - New height in pixels (will be clamped to `minHeight`).
725
+ */
726
+ resizeWindow(handle, x, y, width, height) {
727
+ const windowState = this.windows.get(handle);
728
+ if (!windowState) return;
729
+ const clampedWidth = Math.max(windowState.minWidth, width);
730
+ const clampedHeight = Math.max(windowState.minHeight, height);
731
+ const clampedPosition = this.clampWindowPositionWithinDesktop(x, y, clampedWidth);
732
+ const el = windowState.element;
733
+ const originOffset = this.worldOriginOffset;
734
+ el.style.left = `${clampedPosition.x + originOffset}px`;
735
+ el.style.top = `${clampedPosition.y + originOffset}px`;
736
+ el.style.width = `${clampedWidth}px`;
737
+ el.style.height = `${clampedHeight}px`;
738
+ }
739
+ /** Clamp position so that at least `margin` pixels of the window remain visible within the container. */
740
+ clampWindowPositionWithinDesktop(x, y, windowWidth) {
741
+ if (this.mode.type === "infinite-canvas") return {
742
+ x,
743
+ y
744
+ };
745
+ const containerRect = this.desktopElement.getBoundingClientRect();
746
+ const margin = this.resizeRegionSize * 2;
747
+ return {
748
+ x: Math.max(margin - windowWidth, Math.min(x, containerRect.width - margin)),
749
+ y: Math.max(0, Math.min(y, containerRect.height - margin))
750
+ };
751
+ }
752
+ resolveInitialPosition(x, y) {
753
+ if (x !== void 0 && y !== void 0) return {
754
+ x,
755
+ y
756
+ };
757
+ if (this.positionStrategy.type === "required") throw new Error("Position strategy requires explicit coordinates but none were provided.");
758
+ const getPositionOfCurrentlyFocusedWindow = () => {
759
+ const handle = this.getFocusedWindow();
760
+ if (!handle) return {
761
+ x: 0,
762
+ y: 0
763
+ };
764
+ const rect = this.getWindowRect(handle);
765
+ if (!rect) return {
766
+ x: 0,
767
+ y: 0
768
+ };
769
+ return {
770
+ x: rect.x,
771
+ y: rect.y
772
+ };
773
+ };
774
+ const { offsetX, offsetY } = this.positionStrategy;
775
+ const { x: baseX, y: baseY } = getPositionOfCurrentlyFocusedWindow();
776
+ return {
777
+ x: baseX + offsetX,
778
+ y: baseY + offsetY
779
+ };
780
+ }
781
+ /**
782
+ * Stabilize orderIdx (make them continuous, remove duplicate indices).
783
+ * Apply order to z-index for all windows.
784
+ * This method must be called everytime any orderIdx is changed.
785
+ */
786
+ applyWindowOrdering() {
787
+ const sortedWindows = [...this.windows.values()].sort((a, b) => a.orderIdx - b.orderIdx);
788
+ for (const [idx, state] of sortedWindows.entries()) {
789
+ state.orderIdx = idx;
790
+ state.element.style.zIndex = String(sortedWindows.length - 1 - idx);
791
+ }
792
+ }
793
+ getWindowRectInWorldCoords(windowState) {
794
+ const el = windowState.element;
795
+ const originOffset = this.worldOriginOffset;
796
+ return {
797
+ x: parsePx(el.style.left) - originOffset,
798
+ y: parsePx(el.style.top) - originOffset,
799
+ width: parsePx(el.style.width),
800
+ height: parsePx(el.style.height)
801
+ };
802
+ }
803
+ buildChildrenIndex() {
804
+ const childrenByParent = /* @__PURE__ */ new Map();
805
+ for (const state of this.windows.values()) {
806
+ const parent = state.parentWindow?.handle;
807
+ if (!parent) continue;
808
+ const children = childrenByParent.get(parent);
809
+ if (children) children.push(state);
810
+ else childrenByParent.set(parent, [state]);
811
+ }
812
+ return childrenByParent;
813
+ }
814
+ collectWindowSubtree(handle) {
815
+ const root = this.windows.get(handle);
816
+ if (!root) return [];
817
+ const childrenByParent = this.buildChildrenIndex();
818
+ const result = [];
819
+ const stack = [root];
820
+ let current = stack.pop();
821
+ while (current !== void 0) {
822
+ result.push(current);
823
+ const children = childrenByParent.get(current.handle);
824
+ if (children) for (const child of children) stack.push(child);
825
+ current = stack.pop();
826
+ }
827
+ return result;
828
+ }
829
+ /** Registers an event listener; see the overloads for typed and generic event listeners. */
830
+ addEventListener(type, listener, options) {
831
+ super.addEventListener(type, listener, options);
832
+ }
833
+ /** Removes an event listener; see the overloads for typed and generic event listeners. */
834
+ removeEventListener(type, listener, options) {
835
+ super.removeEventListener(type, listener, options);
836
+ }
837
+ };
838
+ function parsePx(value) {
839
+ return parseInt(value, 10) || 0;
840
+ }
841
+ export { WindowManager as t };