Skip to content

Commit 91ff528

Browse files
authored
feat(dashboard): infer native popup reintegration (#13028) (#13085)
1 parent 046a677 commit 91ff528

4 files changed

Lines changed: 470 additions & 1 deletion

File tree

src/draggable/dashboard/SortZone.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,33 @@ class DashboardSortZone extends SortZone {
256256
return config
257257
}
258258

259+
/**
260+
* @summary Returns a terminal detached popup item for geometry-only reintegration.
261+
*
262+
* Returns the detached terminal popup item represented by a native OS window.
263+
* This is the source side of geometry-only titlebar drag reintegration.
264+
* @param {String} windowId
265+
* @returns {Object|null}
266+
*/
267+
getNativeWindowDrag(windowId) {
268+
let me = this;
269+
270+
if (!me.owner.detachedItems) {
271+
return null
272+
}
273+
274+
for (const [widgetName, detachedItem] of me.owner.detachedItems.entries()) {
275+
if (detachedItem.windowId === windowId && detachedItem.terminalDrop && detachedItem.widget) {
276+
return {
277+
draggedItem: detachedItem.widget,
278+
widgetName
279+
}
280+
}
281+
}
282+
283+
return null
284+
}
285+
259286
/**
260287
* Completes dashboard drag cleanup under the base drag-end latch.
261288
* @param {Object} data The drag end event data.

src/manager/DragCoordinator.mjs

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ class DragCoordinator extends Manager {
1919
* @protected
2020
*/
2121
singleton: true,
22+
/**
23+
* Minimum time a native popup must remain over a remote dashboard target before
24+
* geometry-only reintegration can commit.
25+
* @member {Number} nativeWindowDropDwellMs=450
26+
*/
27+
nativeWindowDropDwellMs: 450,
28+
/**
29+
* Quiescence delay after the last native window-position update before committing
30+
* a geometry-only drop. The browser has no mouseup during OS-titlebar drags.
31+
* @member {Number} nativeWindowDropSettleMs=250
32+
*/
33+
nativeWindowDropSettleMs: 250,
2234
/**
2335
* @member {Map} sortZones=new Map()
2436
* @protected
@@ -32,6 +44,187 @@ class DragCoordinator extends Manager {
3244
*/
3345
activeTargetZone = null
3446

47+
/**
48+
* @member {Map<String,Object>} nativeWindowDropCandidates=new Map()
49+
* @protected
50+
*/
51+
nativeWindowDropCandidates = new Map()
52+
53+
/**
54+
* @summary Clears a pending geometry-only native window-drop candidate.
55+
*
56+
* Clears a pending geometry-only native window-drop candidate.
57+
* @param {String} windowId
58+
*/
59+
clearNativeWindowDropCandidate(windowId) {
60+
let candidate = this.nativeWindowDropCandidates.get(windowId);
61+
62+
if (candidate) {
63+
clearTimeout(candidate.timeoutId);
64+
this.nativeWindowDropCandidates.delete(windowId)
65+
}
66+
}
67+
68+
/**
69+
* @summary Commits an inferred native-titlebar popup drop into the remote dashboard path.
70+
*
71+
* Commits a conservative geometry-only native titlebar drop into the existing remote
72+
* dashboard drop path. The popup is closed only after dwell/settle intent has been inferred.
73+
* @param {String} windowId
74+
* @param {Object} candidate
75+
* @returns {Promise<void>}
76+
*/
77+
async commitNativeWindowDrop(windowId, candidate) {
78+
let me = this,
79+
current = me.nativeWindowDropCandidates.get(windowId);
80+
81+
if (!current || current !== candidate) {
82+
return
83+
}
84+
85+
me.nativeWindowDropCandidates.delete(windowId);
86+
87+
let {
88+
draggedItem,
89+
localX,
90+
localY,
91+
offsetX,
92+
offsetY,
93+
proxyRect,
94+
sourceSortZone,
95+
targetSortZone,
96+
widgetName
97+
} = candidate;
98+
99+
if (sourceSortZone.getNativeWindowDrag?.(windowId)?.draggedItem !== draggedItem) {
100+
return
101+
}
102+
103+
if (!targetSortZone.acceptsRemoteDrag(localX, localY)) {
104+
return
105+
}
106+
107+
await sourceSortZone.suspendWindowDrag(widgetName);
108+
109+
await targetSortZone.onRemoteDragMove({
110+
draggedItem,
111+
localX,
112+
localY,
113+
offsetX,
114+
offsetY,
115+
proxyRect
116+
});
117+
118+
await targetSortZone.onRemoteDrop(draggedItem);
119+
sourceSortZone.onRemoteDropOut(draggedItem);
120+
121+
if (me.activeTargetZone === targetSortZone) {
122+
me.activeTargetZone = null
123+
}
124+
}
125+
126+
/**
127+
* @summary Finds the terminal detached dashboard item represented by a native popup window.
128+
*
129+
* Finds the terminal detached dashboard item represented by a native popup window.
130+
* @param {String} windowId
131+
* @returns {Object|null}
132+
*/
133+
getNativeWindowDragSource(windowId) {
134+
let me = this;
135+
136+
for (const group of me.sortZones.values()) {
137+
for (const sortZone of group.values()) {
138+
let drag = sortZone.getNativeWindowDrag?.(windowId);
139+
140+
if (drag) {
141+
return {
142+
...drag,
143+
sourceSortZone: sortZone
144+
}
145+
}
146+
}
147+
}
148+
149+
return null
150+
}
151+
152+
/**
153+
* @summary Resolves a window at a point while excluding invalid native-titlebar targets.
154+
*
155+
* Resolves the first window at a global point while ignoring window ids that
156+
* cannot be valid native-titlebar drop targets, especially the popup being moved.
157+
* @param {Number} x
158+
* @param {Number} y
159+
* @param {Set<String>} excludedWindowIds
160+
* @returns {String|null}
161+
*/
162+
getWindowAtExcept(x, y, excludedWindowIds) {
163+
let item = Window.items?.find(item => !excludedWindowIds.has(item.id) &&
164+
item.outerRect?.intersects({bottom: y, right: x, x, y}));
165+
166+
return item ? item.id : null
167+
}
168+
169+
/**
170+
* @summary Resolves native popup geometry to a remote dashboard drop candidate.
171+
*
172+
* Resolves a native popup's current geometry to a remote dashboard drop candidate.
173+
* @param {Object} data
174+
* @param {String} data.windowId
175+
* @param {Object} sourceDrag
176+
* @returns {Object|null}
177+
*/
178+
getNativeWindowDropCandidate(data, sourceDrag) {
179+
let me = this,
180+
{sourceSortZone} = sourceDrag,
181+
popupWindow = Window.get(data.windowId),
182+
popupRect = popupWindow?.innerRect,
183+
{sortGroup} = sourceSortZone,
184+
targetWindowId, targetSortZone, targetWindow, localX, localY, width, height;
185+
186+
if (!popupRect || !sortGroup) {
187+
return null
188+
}
189+
190+
localX = popupRect.x + popupRect.width / 2;
191+
localY = popupRect.y + popupRect.height / 2;
192+
193+
targetWindowId = me.getWindowAtExcept(localX, localY, new Set([data.windowId, sourceSortZone.windowId]));
194+
195+
if (!targetWindowId) {
196+
return null
197+
}
198+
199+
targetSortZone = me.sortZones.get(sortGroup)?.get(targetWindowId);
200+
targetWindow = Window.get(targetWindowId);
201+
202+
if (!targetSortZone || !targetWindow?.innerRect) {
203+
return null
204+
}
205+
206+
localX = localX - targetWindow.innerRect.x;
207+
localY = localY - targetWindow.innerRect.y;
208+
209+
if (!targetSortZone.acceptsRemoteDrag(localX, localY)) {
210+
return null
211+
}
212+
213+
width = popupRect.width;
214+
height = popupRect.height;
215+
216+
return {
217+
...sourceDrag,
218+
localX,
219+
localY,
220+
offsetX : width / 2,
221+
offsetY : height / 2,
222+
proxyRect: new Rectangle(localX - width / 2, localY - height / 2, width, height),
223+
targetSortZone,
224+
targetWindowId
225+
}
226+
}
227+
35228
/**
36229
* @param {Neo.draggable.container.SortZone} sourceSortZone
37230
* @param {Neo.component.Base} draggedItem
@@ -114,6 +307,57 @@ class DragCoordinator extends Manager {
114307
me.handleVoid(sourceSortZone, draggedItem, proxyRect)
115308
}
116309

310+
/**
311+
* @summary Handles geometry updates for native OS-titlebar popup reintegration.
312+
*
313+
* Consumes high-frequency window geometry updates for native OS-titlebar popup drags,
314+
* where the browser does not emit pointer move/up events. A terminal detached popup
315+
* reintegrates only after it remains over a remote dashboard target long enough to
316+
* satisfy the settle/dwell intent contract.
317+
* @param {Object} data
318+
* @param {String} data.windowId
319+
*/
320+
onWindowPositionChange(data) {
321+
let me = this,
322+
{windowId} = data,
323+
sourceDrag = me.getNativeWindowDragSource(windowId),
324+
candidate, current, dwellRemaining, firstSeenAt, delay, now;
325+
326+
if (!sourceDrag) {
327+
me.clearNativeWindowDropCandidate(windowId);
328+
return
329+
}
330+
331+
candidate = me.getNativeWindowDropCandidate(data, sourceDrag);
332+
333+
if (!candidate) {
334+
me.clearNativeWindowDropCandidate(windowId);
335+
return
336+
}
337+
338+
now = Date.now();
339+
current = me.nativeWindowDropCandidates.get(windowId);
340+
341+
firstSeenAt = current?.targetSortZone === candidate.targetSortZone &&
342+
current?.draggedItem === candidate.draggedItem
343+
? current.firstSeenAt
344+
: now;
345+
346+
me.clearNativeWindowDropCandidate(windowId);
347+
348+
dwellRemaining = Math.max(0, me.nativeWindowDropDwellMs - (now - firstSeenAt));
349+
delay = Math.max(me.nativeWindowDropSettleMs, dwellRemaining);
350+
351+
candidate.firstSeenAt = firstSeenAt;
352+
candidate.timeoutId = setTimeout(() => {
353+
me.commitNativeWindowDrop(windowId, candidate).catch(error => {
354+
(Neo.logError || console.error)('Native window drop failed', error)
355+
})
356+
}, delay);
357+
358+
me.nativeWindowDropCandidates.set(windowId, candidate)
359+
}
360+
117361
/**
118362
* Finalizes a dashboard window drag by either dropping into the active target
119363
* or leaving the source popup as the terminal drop state.
@@ -168,6 +412,12 @@ class DragCoordinator extends Manager {
168412
me.sortZones.delete(sortGroup)
169413
}
170414
}
415+
416+
for (const [windowId, candidate] of me.nativeWindowDropCandidates.entries()) {
417+
if (candidate.sourceSortZone === sortZone || candidate.targetSortZone === sortZone) {
418+
me.clearNativeWindowDropCandidate(windowId)
419+
}
420+
}
171421
}
172422

173423
/**

src/worker/App.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,7 @@ class App extends Base {
812812
onWindowPositionChange({data}) {
813813
// Only available in shared workers
814814
Neo.manager.Window?.onWindowPositionChange(data);
815+
Neo.manager.DragCoordinator?.onWindowPositionChange(data);
815816

816817
this.fireMainViewsEvent('windowPositionChange', data)
817818
}

0 commit comments

Comments
 (0)