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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- 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
|
+
}
|