Skip to content

Commit 1c9e0d6

Browse files
MingTianSangHongzhi Wenclaude
authored
修复退出后小猫 return-ball 拖拽被系统截图等中断时可能粘住鼠标的问题 (#1661)
* 修复退出后小猫 return-ball 拖拽被系统截图等中断时可能粘住鼠标的问题 * releaseScreenX/releaseScreenY 现在用 Number.isFinite(value) ? value : fallback,不会再把合法的 0 当成缺失值 * suppressClick 时也会派发 neko:return-ball-manual-move 的 return-ball-drag-end,带 movedDistancePx: 0 和 dragCancelled: true,让 avatar-ui-buttons.js 能恢复/重排 CAT1 journey * moved 分支拖拽取消时也传播 dragCancelled cancelActiveDrag 不检查 hasMoved,已移动过的返回球被截图/blur/12s 超时中断时会走 finishDrag 的 moved 分支,而该分支原本只发普通 return-ball-drag-end、不带 dragCancelled, 导致 app-auto-goodbye.js 把这次「丢失释放」当真实拖拽结束、照常降级猫档/回猫,架空了 no-move 分支已加的取消处理。moved 分支两处 dispatch 补上 dragCancelled: suppressClick, 与 no-move 分支对偶。补一条静态断言守回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d8d36dd commit 1c9e0d6

4 files changed

Lines changed: 184 additions & 5 deletions

File tree

static/app-auto-goodbye.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,7 @@
749749
window.addEventListener('mmd-return-click', handleReturn);
750750
window.addEventListener('neko:return-ball-manual-move', (event) => {
751751
const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {};
752-
if (detail.reason === 'return-ball-drag-end') {
752+
if (detail.reason === 'return-ball-drag-end' && detail.dragCancelled !== true) {
753753
handleReturnBallDragEnd();
754754
}
755755
});

static/app-ui.js

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,8 @@
14701470
// ================================================================
14711471

14721472
const MULTI_WINDOW_RETURN_BALL_DRAG_SHRINK_SIZE = 160;
1473+
const RETURN_BALL_DRAG_RECOVERY_POLL_MS = 250;
1474+
const RETURN_BALL_DRAG_STALE_RECOVERY_MS = 12000;
14731475
const MULTI_WINDOW_RETURN_BALL_DRAG_SHRINK_FALLBACK_MS = 220;
14741476
const MULTI_WINDOW_RETURN_BALL_DRAG_RESTORE_FALLBACK_MS = 600;
14751477
const MULTI_WINDOW_RETURN_BALL_REVEAL_FALLBACK_MS = 600;
@@ -2263,6 +2265,16 @@
22632265
}
22642266
}
22652267

2268+
function clearReturnBallDragRecoveryTimer(state) {
2269+
if (!state || !state.dragRecoveryTimer) return;
2270+
clearTimeout(state.dragRecoveryTimer);
2271+
state.dragRecoveryTimer = null;
2272+
}
2273+
2274+
function getReturnBallDragScreenCoordinate(value, fallback) {
2275+
return Number.isFinite(value) ? value : fallback;
2276+
}
2277+
22662278
function isNativeReturnBallDragDisabled() {
22672279
const runtime = window.__NEKO_DESKTOP_RUNTIME__ || {};
22682280
return !!(
@@ -2275,17 +2287,29 @@
22752287
const state = multiWindowReturnBallDragState;
22762288
if (!state) return;
22772289

2290+
const shouldStopNativeDrag = state.isDragging;
2291+
const stopScreenX = getReturnBallDragScreenCoordinate(state.releaseScreenX, state.startScreenX);
2292+
const stopScreenY = getReturnBallDragScreenCoordinate(state.releaseScreenY, state.startScreenY);
2293+
22782294
state.dragSessionToken += 1;
2295+
state.isDragging = false;
2296+
clearReturnBallDragRecoveryTimer(state);
22792297
clearMultiWindowReturnBallDeferredWork(state);
22802298
if (state.container) {
22812299
state.container.removeEventListener('mousedown', state.handleMouseDown, true);
22822300
state.container.removeEventListener('touchstart', state.handleTouchStart, true);
22832301
}
22842302
document.removeEventListener('mousemove', state.handleMouseMove);
22852303
document.removeEventListener('mouseup', state.handleMouseUp);
2304+
document.removeEventListener('pointermove', state.handlePointerMove, true);
2305+
document.removeEventListener('pointerup', state.handlePointerUp, true);
2306+
document.removeEventListener('pointercancel', state.handlePointerCancel, true);
22862307
document.removeEventListener('touchmove', state.handleTouchMove);
22872308
document.removeEventListener('touchend', state.handleTouchEnd);
22882309
document.removeEventListener('touchcancel', state.handleTouchEnd);
2310+
window.removeEventListener('blur', state.handleWindowBlur);
2311+
window.removeEventListener('pagehide', state.handlePageHide);
2312+
document.removeEventListener('visibilitychange', state.handleVisibilityChange);
22892313

22902314
if (state.container) {
22912315
restoreSavedReturnBallStyle(state.container, state);
@@ -2294,6 +2318,18 @@
22942318
}
22952319
delete document.body.dataset.nekoBallDrag;
22962320
multiWindowReturnBallDragState = null;
2321+
2322+
if (shouldStopNativeDrag && window.nekoPetDrag && typeof window.nekoPetDrag.stop === 'function') {
2323+
Promise.resolve()
2324+
.then(() => window.nekoPetDrag.stop(stopScreenX, stopScreenY))
2325+
.finally(() => {
2326+
if (window.nekoPetDrag && typeof window.nekoPetDrag.reveal === 'function') {
2327+
return window.nekoPetDrag.reveal();
2328+
}
2329+
return null;
2330+
})
2331+
.catch(() => {});
2332+
}
22972333
}
22982334

22992335
function ensureMultiWindowReturnBallDrag(container) {
@@ -2328,12 +2364,20 @@
23282364
viewportWaitFallbackTimer: null,
23292365
transitionCleanupTimer: null,
23302366
dragSessionToken: 0,
2367+
dragRecoveryTimer: null,
2368+
lastPointerEventAt: 0,
23312369
handleMouseDown: null,
23322370
handleMouseMove: null,
23332371
handleMouseUp: null,
2372+
handlePointerMove: null,
2373+
handlePointerUp: null,
2374+
handlePointerCancel: null,
23342375
handleTouchStart: null,
23352376
handleTouchMove: null,
23362377
handleTouchEnd: null,
2378+
handleWindowBlur: null,
2379+
handlePageHide: null,
2380+
handleVisibilityChange: null,
23372381
};
23382382

23392383
function getTouchScreenPoint(touch) {
@@ -2453,6 +2497,51 @@
24532497
dispatchClickEvent();
24542498
}
24552499

2500+
function markDragPointerActivity() {
2501+
state.lastPointerEventAt = Date.now();
2502+
}
2503+
2504+
function cancelActiveDrag(reason) {
2505+
if (!state.isDragging) return;
2506+
const screenX = getReturnBallDragScreenCoordinate(state.releaseScreenX, state.startScreenX);
2507+
const screenY = getReturnBallDragScreenCoordinate(state.releaseScreenY, state.startScreenY);
2508+
void finishDrag(screenX, screenY, {
2509+
reason: reason || 'return-ball-drag-cancel',
2510+
suppressClick: true
2511+
});
2512+
}
2513+
2514+
function scheduleReturnBallDragRecoveryCheck() {
2515+
clearReturnBallDragRecoveryTimer(state);
2516+
if (!state.isDragging) return;
2517+
state.dragRecoveryTimer = setTimeout(() => {
2518+
state.dragRecoveryTimer = null;
2519+
if (!state.isDragging) return;
2520+
if (document.hidden) {
2521+
cancelActiveDrag('document-hidden');
2522+
return;
2523+
}
2524+
if (Date.now() - state.lastPointerEventAt > RETURN_BALL_DRAG_STALE_RECOVERY_MS) {
2525+
cancelActiveDrag('stale-pointer-timeout');
2526+
return;
2527+
}
2528+
scheduleReturnBallDragRecoveryCheck();
2529+
}, RETURN_BALL_DRAG_RECOVERY_POLL_MS);
2530+
}
2531+
2532+
function finishDragIfMouseButtonReleased(event, reason) {
2533+
if (!state.isDragging || !event || (event.pointerType && event.pointerType !== 'mouse')) {
2534+
return false;
2535+
}
2536+
if (!Number.isFinite(event.buttons) || event.buttons !== 0) {
2537+
return false;
2538+
}
2539+
void finishDrag(event.screenX, event.screenY, {
2540+
reason: reason || 'buttons-released'
2541+
});
2542+
return true;
2543+
}
2544+
24562545
function isViewportRestored(expectedWidth, expectedHeight) {
24572546
if (!Number.isFinite(expectedWidth) || !Number.isFinite(expectedHeight)) {
24582547
return true;
@@ -2600,6 +2689,7 @@
26002689
state.releaseScreenY = screenY;
26012690
state.savedWindowW = window.innerWidth;
26022691
state.savedWindowH = window.innerHeight;
2692+
markDragPointerActivity();
26032693

26042694
const rect = container.getBoundingClientRect();
26052695
state.savedBallWidth = Math.round(rect.width) || 64;
@@ -2664,6 +2754,7 @@
26642754
continueOnFallback: true
26652755
}
26662756
);
2757+
scheduleReturnBallDragRecoveryCheck();
26672758

26682759
if (event) {
26692760
event.preventDefault();
@@ -2673,6 +2764,7 @@
26732764

26742765
function updateDrag(screenX, screenY) {
26752766
if (!state.isDragging) return;
2767+
markDragPointerActivity();
26762768
state.releaseScreenX = screenX;
26772769
state.releaseScreenY = screenY;
26782770

@@ -2705,10 +2797,13 @@
27052797
async function finishDrag(screenX, screenY) {
27062798
if (!state.isDragging) return;
27072799

2800+
const options = arguments[2] && typeof arguments[2] === 'object' ? arguments[2] : {};
2801+
const suppressClick = options.suppressClick === true;
27082802
state.isDragging = false;
27092803
state.releaseScreenX = screenX;
27102804
state.releaseScreenY = screenY;
27112805
const dragToken = state.dragSessionToken;
2806+
clearReturnBallDragRecoveryTimer(state);
27122807
clearMultiWindowReturnBallDeferredWork(state);
27132808

27142809
// 先瞬间隐藏球,防止恢复 UI 时球在 (8,8) 闪烁
@@ -2732,9 +2827,23 @@
27322827
restoreSavedBallStyle();
27332828
delete document.body.dataset.nekoBallDrag;
27342829
container.setAttribute('data-dragging', 'false');
2735-
scheduleIdleReturnBallDesktopBridge('return-ball-drag-click', container);
2830+
scheduleIdleReturnBallDesktopBridge(
2831+
suppressClick ? 'return-ball-drag-cancel' : 'return-ball-drag-click',
2832+
container
2833+
);
27362834
revealReturnBallDragWindow();
2737-
dispatchReturnBallClick();
2835+
if (suppressClick) {
2836+
window.dispatchEvent(new CustomEvent('neko:return-ball-manual-move', {
2837+
detail: {
2838+
reason: 'return-ball-drag-end',
2839+
container: container,
2840+
movedDistancePx: 0,
2841+
dragCancelled: true
2842+
}
2843+
}));
2844+
} else {
2845+
dispatchReturnBallClick();
2846+
}
27382847
}, {
27392848
fallbackMs: MULTI_WINDOW_RETURN_BALL_DRAG_RESTORE_FALLBACK_MS,
27402849
continueOnFallback: true
@@ -2778,7 +2887,8 @@
27782887
detail: {
27792888
reason: 'return-ball-drag-end',
27802889
container: container,
2781-
movedDistancePx: movedDistancePx
2890+
movedDistancePx: movedDistancePx,
2891+
dragCancelled: suppressClick
27822892
}
27832893
}));
27842894
revealReturnBallDragWindow();
@@ -2796,7 +2906,8 @@
27962906
detail: {
27972907
reason: 'return-ball-drag-end',
27982908
container: container,
2799-
movedDistancePx: movedDistancePx
2909+
movedDistancePx: movedDistancePx,
2910+
dragCancelled: suppressClick
28002911
}
28012912
}));
28022913
revealReturnBallDragWindow();
@@ -2822,11 +2933,24 @@
28222933
beginDrag(event.screenX, event.screenY, event);
28232934
};
28242935
state.handleMouseMove = (event) => {
2936+
if (finishDragIfMouseButtonReleased(event, 'mousemove-buttons-released')) return;
28252937
updateDrag(event.screenX, event.screenY);
28262938
};
28272939
state.handleMouseUp = (event) => {
28282940
void finishDrag(event.screenX, event.screenY);
28292941
};
2942+
state.handlePointerMove = (event) => {
2943+
if (finishDragIfMouseButtonReleased(event, 'pointermove-buttons-released')) return;
2944+
if (event && event.pointerType === 'mouse') {
2945+
updateDrag(event.screenX, event.screenY);
2946+
}
2947+
};
2948+
state.handlePointerUp = (event) => {
2949+
void finishDrag(event.screenX, event.screenY);
2950+
};
2951+
state.handlePointerCancel = () => {
2952+
cancelActiveDrag('pointercancel');
2953+
};
28302954
state.handleTouchStart = (event) => {
28312955
const point = getTouchScreenPoint(event.touches[0]);
28322956
if (!point) return;
@@ -2846,14 +2970,31 @@
28462970
point ? point.y : state.releaseScreenY
28472971
);
28482972
};
2973+
state.handleWindowBlur = () => {
2974+
cancelActiveDrag('window-blur');
2975+
};
2976+
state.handlePageHide = () => {
2977+
cancelActiveDrag('pagehide');
2978+
};
2979+
state.handleVisibilityChange = () => {
2980+
if (document.hidden) {
2981+
cancelActiveDrag('visibility-hidden');
2982+
}
2983+
};
28492984

28502985
container.addEventListener('mousedown', state.handleMouseDown, true);
28512986
container.addEventListener('touchstart', state.handleTouchStart, true);
28522987
document.addEventListener('mousemove', state.handleMouseMove);
28532988
document.addEventListener('mouseup', state.handleMouseUp);
2989+
document.addEventListener('pointermove', state.handlePointerMove, true);
2990+
document.addEventListener('pointerup', state.handlePointerUp, true);
2991+
document.addEventListener('pointercancel', state.handlePointerCancel, true);
28542992
document.addEventListener('touchmove', state.handleTouchMove, { passive: false });
28552993
document.addEventListener('touchend', state.handleTouchEnd);
28562994
document.addEventListener('touchcancel', state.handleTouchEnd);
2995+
window.addEventListener('blur', state.handleWindowBlur);
2996+
window.addEventListener('pagehide', state.handlePageHide);
2997+
document.addEventListener('visibilitychange', state.handleVisibilityChange);
28572998

28582999
multiWindowReturnBallDragState = state;
28593000
}

tests/unit/test_app_auto_goodbye_phase1.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,11 @@ class CustomEventLike {{
407407
dragEnd();
408408
home.tickAll();
409409
assert(home.win.nekoAutoGoodbye.getState().visualTier === 'cat3', 'second CAT3 drag should keep CAT3');
410+
home.win.dispatchEvent(new CustomEventLike('neko:return-ball-manual-move', {{
411+
detail: {{ reason: 'return-ball-drag-end', movedDistancePx: 0, dragCancelled: true }}
412+
}}));
413+
home.tickAll();
414+
assert(home.win.nekoAutoGoodbye.getState().visualTier === 'cat3', 'cancelled CAT3 drag should not count toward drag demotion');
410415
dragEnd();
411416
home.tickAll();
412417
assert(home.win.nekoAutoGoodbye.getState().visualTier === 'cat2', 'third CAT3 drag should step back to CAT2');

tests/unit/test_avatar_return_button_idle_tiers_static.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,39 @@ def test_desktop_return_ball_drag_lifecycle_waits_for_restored_viewport_before_r
290290
assert no_move_block.index("revealReturnBallDragWindow();") < no_move_block.index("dispatchReturnBallClick();")
291291

292292

293+
def test_desktop_return_ball_drag_recovers_when_mouse_release_is_lost():
294+
source = APP_UI_PATH.read_text(encoding="utf-8")
295+
296+
assert "RETURN_BALL_DRAG_RECOVERY_POLL_MS = 250" in source
297+
assert "RETURN_BALL_DRAG_STALE_RECOVERY_MS = 12000" in source
298+
assert "function getReturnBallDragScreenCoordinate(value, fallback)" in source
299+
assert "Number.isFinite(value) ? value : fallback" in source
300+
assert "state.releaseScreenX || state.startScreenX" not in source
301+
assert "state.releaseScreenY || state.startScreenY" not in source
302+
assert "function finishDragIfMouseButtonReleased(event, reason)" in source
303+
assert "event.pointerType && event.pointerType !== 'mouse'" in source
304+
assert "event.buttons !== 0" in source
305+
assert "cancelActiveDrag('window-blur')" in source
306+
assert "cancelActiveDrag('visibility-hidden')" in source
307+
assert "cancelActiveDrag('pagehide')" in source
308+
assert "cancelActiveDrag('pointercancel')" in source
309+
assert "cancelActiveDrag('stale-pointer-timeout')" in source
310+
assert "document.addEventListener('pointermove', state.handlePointerMove, true)" in source
311+
assert "document.addEventListener('pointerup', state.handlePointerUp, true)" in source
312+
assert "document.addEventListener('pointercancel', state.handlePointerCancel, true)" in source
313+
assert "window.addEventListener('blur', state.handleWindowBlur)" in source
314+
assert "document.addEventListener('visibilitychange', state.handleVisibilityChange)" in source
315+
assert "suppressClick ? 'return-ball-drag-cancel' : 'return-ball-drag-click'" in source
316+
assert "if (suppressClick)" in source
317+
assert "dragCancelled: true" in source
318+
assert "movedDistancePx: 0" in source
319+
assert "dispatchReturnBallClick();" in source
320+
assert "window.nekoPetDrag.stop(stopScreenX, stopScreenY)" in source
321+
# 已经移动过的拖拽被中断(截图/blur/超时)时也要传播取消标记,
322+
# 否则 moved 分支照常派发 drag-end,app-auto-goodbye 会当成真实释放降级猫档
323+
assert "dragCancelled: suppressClick" in source
324+
325+
293326
def test_return_button_drag_has_single_owner_per_runtime_path():
294327
avatar_source = AVATAR_UI_BUTTONS_PATH.read_text(encoding="utf-8")
295328
live2d_source = LIVE2D_UI_BUTTONS_PATH.read_text(encoding="utf-8")

0 commit comments

Comments
 (0)