flexdesk 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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * WindowChromeController
3
+ *
4
+ * Handles frameless window management for the desktop application:
5
+ * - Window minimize, maximize, close buttons
6
+ * - Resize handles (edges and corners)
7
+ * - Top bar dragging for window movement
8
+ * - F11 fullscreen toggle
9
+ *
10
+ * Talks to the embedder ONLY through a Host's `window` capability
11
+ * (see ui/js/host/host.js). A host with no `window` capability — a browser
12
+ * tab, a VS Code webview, the standalone demo — is legal: we simply don't
13
+ * install custom chrome. That degraded path is also the production path on
14
+ * Linux, macOS and `--browser`, where the OS/browser draws the title bar.
15
+ */
16
+
17
+ const SUPPORTS_POINTER = typeof window !== 'undefined' && 'onpointerdown' in window;
18
+ const MIN_WIDTH = 500;
19
+ const MIN_HEIGHT = 350;
20
+ const DRAG_THRESHOLD = 4;
21
+
22
+ export class WindowChromeController {
23
+ constructor({ eventBus, logger, host } = {}) {
24
+ this.eventBus = eventBus;
25
+ this.logger = logger || console;
26
+
27
+ // The host's window capability, or null. Never throw on a missing
28
+ // one — see the class comment.
29
+ this.win = host?.window || null;
30
+
31
+ this.resizingActive = false;
32
+ this.lastDblClickTs = 0;
33
+ this.handles = {};
34
+ this.topBar = null;
35
+
36
+ this._disposed = false;
37
+ this._disposers = [];
38
+ }
39
+
40
+ /**
41
+ * Initialize the window chrome controller
42
+ */
43
+ initialize() {
44
+ if (this._disposed) return;
45
+
46
+ if (!this.win || this.win.chrome() !== 'custom') {
47
+ // Native window decorations (Linux/macOS) or the browser already
48
+ // provide a title bar + window controls; our custom chrome would be
49
+ // a redundant second set. Skip it entirely.
50
+ this.logger.info?.('[WindowChrome] Native/browser chrome detected — custom window chrome disabled');
51
+ return;
52
+ }
53
+
54
+ this.#wireWindowButtons();
55
+ this.#createResizeHandles();
56
+ this.#wireTopBarDrag();
57
+ this.#wireKeyboardShortcuts();
58
+ this.#wireNativeStateSync();
59
+ this.#initializeState();
60
+
61
+ this.logger.info?.('[WindowChrome] Initialized');
62
+ }
63
+
64
+ /**
65
+ * Wire minimize, maximize, close buttons
66
+ */
67
+ #wireWindowButtons() {
68
+ const minBtn = document.getElementById('win-minimize');
69
+ const maxBtn = document.getElementById('win-maximize');
70
+ const closeBtn = document.getElementById('win-close');
71
+
72
+ if (minBtn) {
73
+ const handler = () => this.win.minimize();
74
+ minBtn.addEventListener('click', handler);
75
+ this._disposers.push(() => minBtn.removeEventListener('click', handler));
76
+ }
77
+
78
+ if (maxBtn) {
79
+ const handler = async () => {
80
+ try {
81
+ const isMax = await this.win.setMaximized(!(await this.win.isMaximized()));
82
+ this.#updateMaximizeIcon(isMax);
83
+ this.#setResizeHandlesEnabled(!isMax);
84
+ } catch (e) {
85
+ this.logger.warn?.('[WindowChrome] Maximize toggle failed', e);
86
+ }
87
+ };
88
+ maxBtn.addEventListener('click', handler);
89
+ this._disposers.push(() => maxBtn.removeEventListener('click', handler));
90
+ }
91
+
92
+ if (closeBtn) {
93
+ const handler = () => this.win.close();
94
+ closeBtn.addEventListener('click', handler);
95
+ this._disposers.push(() => closeBtn.removeEventListener('click', handler));
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Update maximize button icon and body class
101
+ */
102
+ #updateMaximizeIcon(isMax) {
103
+ const maxBtn = document.getElementById('win-maximize');
104
+ if (!maxBtn) return;
105
+
106
+ const icon = maxBtn.querySelector('.material-symbols-outlined');
107
+ if (icon) {
108
+ icon.textContent = isMax ? 'filter_none' : 'check_box_outline_blank';
109
+ }
110
+ const tooltipText = isMax ? 'Restore' : 'Maximize';
111
+ window.LatexTooltip?.set(maxBtn, tooltipText);
112
+ maxBtn.setAttribute('aria-label', tooltipText);
113
+
114
+ document.body.classList.toggle('twm-window-maximized', isMax);
115
+ }
116
+
117
+ /**
118
+ * Enable/disable resize handles
119
+ */
120
+ #setResizeHandlesEnabled(enabled) {
121
+ Object.values(this.handles).forEach(handle => {
122
+ if (handle) {
123
+ handle.style.pointerEvents = enabled ? 'auto' : 'none';
124
+ }
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Set fullscreen body class
130
+ */
131
+ #setFullscreenClass(isFs) {
132
+ document.body.classList.toggle('window-fullscreen', isFs);
133
+ }
134
+
135
+ /**
136
+ * Create all resize handles
137
+ */
138
+ #createResizeHandles() {
139
+ const positions = ['right', 'left', 'top', 'bottom'];
140
+ const corners = ['br', 'bl', 'tr', 'tl'];
141
+
142
+ positions.forEach(pos => {
143
+ const handle = document.createElement('div');
144
+ handle.className = `twm-resize-handle ${pos}`;
145
+ document.body.appendChild(handle);
146
+ this.handles[pos] = handle;
147
+ this.#setupResize(handle, pos);
148
+ });
149
+
150
+ corners.forEach(corner => {
151
+ const handle = document.createElement('div');
152
+ handle.className = `twm-resize-handle corner ${corner}`;
153
+ document.body.appendChild(handle);
154
+ const mode = 'corner' + corner.toUpperCase();
155
+ this.handles[mode] = handle;
156
+ this.#setupResize(handle, mode);
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Setup resize behavior for a handle
162
+ */
163
+ #setupResize(handle, mode) {
164
+ let startX = 0, startY = 0, startScreenX = 0, startScreenY = 0;
165
+ let startW = 0, startH = 0, startLeft = 0, startTop = 0;
166
+ let pending = null;
167
+ let rafScheduled = false;
168
+ let lastSent = null;
169
+
170
+ const boundsEqual = (a, b) => {
171
+ return !!a && !!b && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
172
+ };
173
+
174
+ const sendBounds = () => {
175
+ rafScheduled = false;
176
+ if (!pending) return;
177
+ if (lastSent && boundsEqual(lastSent, pending)) return;
178
+ this.win.setBounds({ x: pending.x, y: pending.y, width: pending.w, height: pending.h });
179
+ lastSent = pending;
180
+ };
181
+
182
+ const scheduleSend = () => {
183
+ if (!rafScheduled) {
184
+ rafScheduled = true;
185
+ requestAnimationFrame(sendBounds);
186
+ }
187
+ };
188
+
189
+ let activePointerId = null;
190
+
191
+ const startDrag = async (e) => {
192
+ e.preventDefault();
193
+ this.resizingActive = true;
194
+ startX = e.clientX;
195
+ startY = e.clientY;
196
+ startScreenX = e.screenX ?? startX;
197
+ startScreenY = e.screenY ?? startY;
198
+
199
+ try {
200
+ const res = await this.win.getBounds();
201
+ if (res) {
202
+ startLeft = typeof res.x === 'number' ? res.x : 0;
203
+ startTop = typeof res.y === 'number' ? res.y : 0;
204
+ startW = res.width || document.documentElement.clientWidth;
205
+ startH = res.height || document.documentElement.clientHeight;
206
+ } else {
207
+ startLeft = 0;
208
+ startTop = 0;
209
+ startW = document.documentElement.clientWidth;
210
+ startH = document.documentElement.clientHeight;
211
+ }
212
+ } catch (e) {
213
+ startLeft = 0;
214
+ startTop = 0;
215
+ startW = document.documentElement.clientWidth;
216
+ startH = document.documentElement.clientHeight;
217
+ }
218
+
219
+ startLeft = Math.round(startLeft);
220
+ startTop = Math.round(startTop);
221
+ startW = Math.round(startW);
222
+ startH = Math.round(startH);
223
+ pending = null;
224
+ lastSent = null;
225
+ rafScheduled = false;
226
+
227
+ // Capture pointer for smooth resize even when cursor escapes the handle
228
+ if (SUPPORTS_POINTER && e.pointerId != null && handle.setPointerCapture) {
229
+ try {
230
+ handle.setPointerCapture(e.pointerId);
231
+ activePointerId = e.pointerId;
232
+ } catch (_) {}
233
+ }
234
+
235
+ if (SUPPORTS_POINTER) {
236
+ window.addEventListener('pointermove', onMove, { passive: false });
237
+ window.addEventListener('pointerup', endDrag, { once: true });
238
+ } else {
239
+ window.addEventListener('mousemove', onMove, { passive: false });
240
+ window.addEventListener('mouseup', endDrag, { once: true });
241
+ }
242
+ };
243
+
244
+ const onMove = (e) => {
245
+ const sx = e.screenX ?? e.clientX ?? 0;
246
+ const sy = e.screenY ?? e.clientY ?? 0;
247
+ const dx = sx - startScreenX;
248
+ const dy = sy - startScreenY;
249
+
250
+ let newX = startLeft;
251
+ let newY = startTop;
252
+ let newW = startW;
253
+ let newH = startH;
254
+
255
+ const isLeft = mode === 'left' || mode === 'cornerBL' || mode === 'cornerTL';
256
+ const isRight = mode === 'right' || mode === 'cornerBR' || mode === 'cornerTR';
257
+ const isTop = mode === 'top' || mode === 'cornerTR' || mode === 'cornerTL';
258
+ const isBottom = mode === 'bottom' || mode === 'cornerBR' || mode === 'cornerBL';
259
+
260
+ if (isLeft) {
261
+ const candidateWidth = startW - dx;
262
+ newW = Math.max(MIN_WIDTH, candidateWidth);
263
+ const shift = startW - newW;
264
+ newX = startLeft + shift;
265
+ } else if (isRight) {
266
+ newW = Math.max(MIN_WIDTH, startW + dx);
267
+ }
268
+
269
+ if (isTop) {
270
+ const candidateHeight = startH - dy;
271
+ newH = Math.max(MIN_HEIGHT, candidateHeight);
272
+ const shiftY = startH - newH;
273
+ newY = startTop + shiftY;
274
+ } else if (isBottom) {
275
+ newH = Math.max(MIN_HEIGHT, startH + dy);
276
+ }
277
+
278
+ newX = Math.round(newX);
279
+ newY = Math.round(newY);
280
+ newW = Math.max(MIN_WIDTH, Math.round(newW));
281
+ newH = Math.max(MIN_HEIGHT, Math.round(newH));
282
+
283
+ pending = { x: newX, y: newY, w: newW, h: newH };
284
+ scheduleSend();
285
+ };
286
+
287
+ const endDrag = () => {
288
+ if (SUPPORTS_POINTER) {
289
+ window.removeEventListener('pointermove', onMove);
290
+ } else {
291
+ window.removeEventListener('mousemove', onMove);
292
+ }
293
+ // Release pointer capture
294
+ if (activePointerId != null && handle.releasePointerCapture) {
295
+ try { handle.releasePointerCapture(activePointerId); } catch (_) {}
296
+ activePointerId = null;
297
+ }
298
+ // Flush any pending bounds update that was waiting for rAF
299
+ if (pending && (!lastSent || !boundsEqual(lastSent, pending))) {
300
+ this.win.setBounds({ x: pending.x, y: pending.y, width: pending.w, height: pending.h });
301
+ }
302
+ pending = null;
303
+ lastSent = null;
304
+ this.resizingActive = false;
305
+ };
306
+
307
+ const event = SUPPORTS_POINTER ? 'pointerdown' : 'mousedown';
308
+ handle.addEventListener(event, startDrag);
309
+ this._disposers.push(() => handle.removeEventListener(event, startDrag));
310
+ }
311
+
312
+ /**
313
+ * Setup top bar dragging for window movement
314
+ */
315
+ #wireTopBarDrag() {
316
+ this.topBar = document.querySelector('.twm-global-top-bar');
317
+ if (!this.topBar) return;
318
+
319
+ const NO_DRAG_SELECTOR = '.no-drag, button, input, select, textarea, .menu-item, .menu-dropdown, .window-controls, .panel-toggles, .global-search-dropdown';
320
+
321
+ let dragging = false;
322
+ let startSX = 0, startSY = 0, startX = null, startY = null, winW = 0, winH = 0;
323
+ let wasMax = false;
324
+ let didRestore = false;
325
+ let grabRatio = 0.5;
326
+ let raf = null;
327
+ let lastSentX = null, lastSentY = null;
328
+ let activePointerId = null;
329
+
330
+ // Double-click detection (native drag swallows dblclick events)
331
+ const DBL_CLICK_TIME = 500;
332
+ const DBL_CLICK_DIST = 4;
333
+ let lastDownTime = 0;
334
+ let lastDownX = 0;
335
+ let lastDownY = 0;
336
+
337
+ const isInteractiveTarget = (t) => !!(t && t.closest && t.closest(NO_DRAG_SELECTOR));
338
+
339
+ const onMove = (e) => {
340
+ // IMPORTANT: This must NOT be async to avoid race conditions on first drag
341
+ const sx = e.screenX ?? e.clientX ?? 0;
342
+ const sy = e.screenY ?? e.clientY ?? 0;
343
+ const dx = Math.abs(sx - startSX);
344
+ const dy = Math.abs(sy - startSY);
345
+
346
+ if (!dragging) {
347
+ if (wasMax) {
348
+ if ((dx + dy) <= 0) return;
349
+ // Handle restore from maximized state
350
+ if (!didRestore) {
351
+ didRestore = true;
352
+ // Fire-and-forget the restore, then reposition
353
+ this.win.setMaximized(false).then(() => {
354
+ this.#updateMaximizeIcon(false);
355
+ this.#setResizeHandlesEnabled(true);
356
+ return this.win.getBounds();
357
+ }).then((bounds) => {
358
+ if (bounds) {
359
+ const rw = bounds.width || 800;
360
+ const rh = bounds.height || 600;
361
+ const targetX = Math.max(0, Math.round(startSX - (rw * grabRatio)));
362
+ const targetY = Math.max(0, Math.round(sy - 10));
363
+ this.win.setBounds({ x: targetX, y: targetY, width: rw, height: rh });
364
+ startX = targetX;
365
+ startY = targetY;
366
+ winW = rw;
367
+ winH = rh;
368
+ startSX = sx;
369
+ startSY = sy;
370
+ }
371
+ }).catch((err) => {
372
+ this.logger.warn?.('[WindowChrome] Restore on drag failed', err);
373
+ });
374
+ }
375
+ return; // Don't proceed with normal drag until restore completes
376
+ } else {
377
+ if ((dx + dy) < DRAG_THRESHOLD) return;
378
+ // startX/startY should already be set by startDrag - if not, skip this move
379
+ if (startX == null || startY == null) {
380
+ return;
381
+ }
382
+ }
383
+ dragging = true;
384
+ }
385
+
386
+ // Use the bounds fetched in startDrag (never default to 0)
387
+ if (startX == null || startY == null) return;
388
+
389
+ const nx = Math.round(startX + (sx - startSX));
390
+ const ny = Math.round(startY + (sy - startSY));
391
+
392
+ if (lastSentX === nx && lastSentY === ny) return;
393
+
394
+ if (raf) cancelAnimationFrame(raf);
395
+ raf = requestAnimationFrame(() => {
396
+ lastSentX = nx;
397
+ lastSentY = ny;
398
+ this.win.setBounds({ x: nx, y: ny });
399
+ });
400
+ };
401
+
402
+ const endDrag = () => {
403
+ dragging = false;
404
+ if (raf) {
405
+ cancelAnimationFrame(raf);
406
+ raf = null;
407
+ }
408
+ if (activePointerId != null && this.topBar.releasePointerCapture) {
409
+ try {
410
+ this.topBar.releasePointerCapture(activePointerId);
411
+ } catch (e) {}
412
+ }
413
+ activePointerId = null;
414
+ didRestore = false;
415
+
416
+ if (SUPPORTS_POINTER) {
417
+ window.removeEventListener('pointermove', onMove, true);
418
+ window.removeEventListener('pointerup', endDrag, true);
419
+ } else {
420
+ window.removeEventListener('mousemove', onMove, true);
421
+ window.removeEventListener('mouseup', endDrag, true);
422
+ }
423
+ };
424
+
425
+ const startDrag = async (e) => {
426
+ if (this.resizingActive) return;
427
+ if (isInteractiveTarget(e.target)) return;
428
+ if ((Date.now() - this.lastDblClickTs) < 300) return;
429
+
430
+ // Detect double-click before entering native drag (which
431
+ // blocks the message loop and prevents dblclick events).
432
+ const now = Date.now();
433
+ const cx = e.clientX ?? 0;
434
+ const cy = e.clientY ?? 0;
435
+ if ((now - lastDownTime) < DBL_CLICK_TIME
436
+ && Math.abs(cx - lastDownX) < DBL_CLICK_DIST
437
+ && Math.abs(cy - lastDownY) < DBL_CLICK_DIST) {
438
+ lastDownTime = 0;
439
+ this.lastDblClickTs = now;
440
+ e.preventDefault();
441
+ try {
442
+ const isMax = await this.win.setMaximized(!(await this.win.isMaximized()));
443
+ this.#updateMaximizeIcon(isMax);
444
+ this.#setResizeHandlesEnabled(!isMax);
445
+ } catch (err) {
446
+ this.logger.warn?.('[WindowChrome] Double-click maximize failed', err);
447
+ }
448
+ return;
449
+ }
450
+ lastDownTime = now;
451
+ lastDownX = cx;
452
+ lastDownY = cy;
453
+
454
+ try {
455
+ if (!this.win) return;
456
+
457
+ // ── Native drag (enables Aero Snap) ──
458
+ // The call blocks until the OS drag loop ends (mouse-up).
459
+ // On return the window has already moved/snapped.
460
+ if (this.win.startNativeDrag) {
461
+ e.preventDefault();
462
+ if (await this.win.startNativeDrag()) {
463
+ // Sync UI state after native drag (may have snapped/maximized)
464
+ try {
465
+ const isMax = await this.win.isMaximized();
466
+ this.#updateMaximizeIcon(isMax);
467
+ this.#setResizeHandlesEnabled(!isMax);
468
+ } catch (_) {}
469
+ return;
470
+ }
471
+ // Native drag unavailable — fall through to JS drag
472
+ }
473
+
474
+ // ── JS drag fallback (non-Windows) ──
475
+ startSX = e.screenX ?? e.clientX ?? 0;
476
+ startSY = e.screenY ?? e.clientY ?? 0;
477
+
478
+ try {
479
+ wasMax = await this.win.isMaximized();
480
+ } catch (e) {
481
+ wasMax = false;
482
+ }
483
+
484
+ const barWidth = Math.max(1, window.innerWidth || document.documentElement.clientWidth);
485
+ grabRatio = Math.min(1, Math.max(0, (e.clientX || 0) / barWidth));
486
+
487
+ if (!wasMax) {
488
+ const bounds = await this.win.getBounds();
489
+ if (bounds) {
490
+ startX = typeof bounds.x === 'number' ? bounds.x : (window.screenX ?? 0);
491
+ startY = typeof bounds.y === 'number' ? bounds.y : (window.screenY ?? 0);
492
+ winW = bounds.width || document.documentElement.clientWidth;
493
+ winH = bounds.height || document.documentElement.clientHeight;
494
+ } else {
495
+ startX = window.screenX ?? 0;
496
+ startY = window.screenY ?? 0;
497
+ winW = document.documentElement.clientWidth;
498
+ winH = document.documentElement.clientHeight;
499
+ }
500
+ }
501
+
502
+ e.preventDefault();
503
+
504
+ if (SUPPORTS_POINTER && e.pointerId != null && this.topBar.setPointerCapture) {
505
+ try {
506
+ this.topBar.setPointerCapture(e.pointerId);
507
+ activePointerId = e.pointerId;
508
+ } catch (e) {}
509
+ }
510
+
511
+ if (SUPPORTS_POINTER) {
512
+ window.addEventListener('pointermove', onMove, true);
513
+ window.addEventListener('pointerup', endDrag, true);
514
+ } else {
515
+ window.addEventListener('mousemove', onMove, true);
516
+ window.addEventListener('mouseup', endDrag, true);
517
+ }
518
+ } catch (err) {
519
+ this.logger.warn?.('[WindowChrome] Start drag failed', err);
520
+ }
521
+ };
522
+
523
+ // Block drag during resize
524
+ const blocker = (e) => {
525
+ if (this.resizingActive) {
526
+ e.preventDefault();
527
+ e.stopPropagation();
528
+ }
529
+ };
530
+ this.topBar.addEventListener('mousedown', blocker, true);
531
+ this.topBar.addEventListener('pointerdown', blocker, true);
532
+
533
+ // Double-click to toggle maximize (only on blank surfaces)
534
+ const dblClickHandler = async (e) => {
535
+ // Skip maximize if double-click is on interactive elements
536
+ const interactiveSelector = 'button, input, select, textarea, a, [role="button"], [role="menuitem"], .menu-bar, .fl-bar, .fl-item, .global-search-wrapper, .panel-toggle-btn';
537
+ if (e.target.closest(interactiveSelector)) {
538
+ return;
539
+ }
540
+
541
+ this.lastDblClickTs = Date.now();
542
+ e.preventDefault();
543
+ e.stopPropagation();
544
+ if (this.resizingActive) return;
545
+
546
+ try {
547
+ const isMax = await this.win.setMaximized(!(await this.win.isMaximized()));
548
+ this.#updateMaximizeIcon(isMax);
549
+ this.#setResizeHandlesEnabled(!isMax);
550
+ } catch (e) {
551
+ this.logger.warn?.('[WindowChrome] Double-click maximize failed', e);
552
+ }
553
+ };
554
+ this.topBar.addEventListener('dblclick', dblClickHandler);
555
+
556
+ // Start drag
557
+ const event = SUPPORTS_POINTER ? 'pointerdown' : 'mousedown';
558
+ this.topBar.addEventListener(event, startDrag, false);
559
+
560
+ this._disposers.push(() => {
561
+ this.topBar.removeEventListener('mousedown', blocker, true);
562
+ this.topBar.removeEventListener('pointerdown', blocker, true);
563
+ this.topBar.removeEventListener('dblclick', dblClickHandler);
564
+ this.topBar.removeEventListener(event, startDrag, false);
565
+ });
566
+ }
567
+
568
+ /**
569
+ * Listen for browser resize events to sync UI with native state changes
570
+ * (e.g. Win+Arrow maximize/restore, Win+Down minimize).
571
+ */
572
+ #wireNativeStateSync() {
573
+ let syncTimer = null;
574
+ const syncState = async () => {
575
+ if (this.resizingActive || this._disposed) return;
576
+ try {
577
+ if (!this.win) return;
578
+ const isMax = await this.win.isMaximized();
579
+ this.#updateMaximizeIcon(isMax);
580
+ this.#setResizeHandlesEnabled(!isMax);
581
+ } catch (_) {}
582
+ };
583
+ const handler = () => {
584
+ // Debounce: native snap triggers multiple resize events
585
+ clearTimeout(syncTimer);
586
+ syncTimer = setTimeout(syncState, 80);
587
+ };
588
+ window.addEventListener('resize', handler);
589
+ this._disposers.push(() => {
590
+ window.removeEventListener('resize', handler);
591
+ clearTimeout(syncTimer);
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Wire F11 fullscreen toggle
597
+ */
598
+ #wireKeyboardShortcuts() {
599
+ const handler = async (e) => {
600
+ if (e.code === 'F11') {
601
+ e.preventDefault();
602
+ try {
603
+ const isFs = await this.win.toggleFullscreen();
604
+ this.#setFullscreenClass(isFs);
605
+ const max = await this.win.isMaximized();
606
+ this.#setResizeHandlesEnabled(!(isFs || max));
607
+ } catch (e) {
608
+ this.logger.warn?.('[WindowChrome] F11 toggle failed', e);
609
+ }
610
+ }
611
+ };
612
+ window.addEventListener('keydown', handler, true);
613
+ this._disposers.push(() => window.removeEventListener('keydown', handler, true));
614
+ }
615
+
616
+ /**
617
+ * Initialize window state on startup
618
+ */
619
+ async #initializeState() {
620
+ try {
621
+ if (!this.win) return;
622
+
623
+ const isMax = await this.win.isMaximized();
624
+ this.#updateMaximizeIcon(isMax);
625
+ this.#setResizeHandlesEnabled(!isMax);
626
+
627
+ const isFs = await this.win.isFullscreen();
628
+ this.#setFullscreenClass(isFs);
629
+ if (isFs) this.#setResizeHandlesEnabled(false);
630
+ } catch (e) {
631
+ this.logger.warn?.('[WindowChrome] Initial state check failed', e);
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Dispose of the controller
637
+ */
638
+ dispose() {
639
+ if (this._disposed) return;
640
+ this._disposed = true;
641
+
642
+ this._disposers.forEach(dispose => {
643
+ try {
644
+ dispose();
645
+ } catch (e) {}
646
+ });
647
+ this._disposers = [];
648
+
649
+ // Remove resize handles
650
+ Object.values(this.handles).forEach(handle => {
651
+ if (handle && handle.parentNode) {
652
+ handle.parentNode.removeChild(handle);
653
+ }
654
+ });
655
+ this.handles = {};
656
+
657
+ this.logger.info?.('[WindowChrome] Disposed');
658
+ }
659
+ }