Skip to content

Commit 733b362

Browse files
committed
Fix return ball drag safety cleanup
1 parent b40e042 commit 733b362

2 files changed

Lines changed: 203 additions & 11 deletions

File tree

static/avatar-ui-buttons.js

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,6 +2726,10 @@ function _stepNekoIdleCat1Walk(button, timestamp) {
27262726
function _startNekoIdleCat1Walk(button, target) {
27272727
const state = _getNekoIdleCat1Journey(button);
27282728
if (!state) return;
2729+
if (_isNekoIdleReturnDragActionActive(button)) return;
2730+
const walkContainer = _getNekoIdleReturnContainerFromButton(button);
2731+
const walkDragging = walkContainer && walkContainer.getAttribute('data-dragging');
2732+
if (walkDragging && walkDragging !== 'false') return;
27292733
const profile = state.profile;
27302734
state.target = target;
27312735
state.targetKind = target && target.kind ? target.kind : '';
@@ -2963,7 +2967,8 @@ function _refreshNekoIdleCat1Observer(button) {
29632967
const currentState = button.__nekoIdleReturnSubactionState || button.__nekoIdleCat1Journey;
29642968
if (!currentState || currentState.paused) return;
29652969
if (currentState.substate === currentState.profile.walkingSubstate) return;
2966-
if (container.getAttribute('data-dragging') === 'true') return;
2970+
const observerDragging = container.getAttribute('data-dragging');
2971+
if (observerDragging && observerDragging !== 'false') return;
29672972
_scheduleNekoIdleCat1JourneySync(button);
29682973
});
29692974
state.containerObserver.observe(container, {
@@ -2977,6 +2982,9 @@ function _refreshNekoIdleCat1Observer(button) {
29772982
function _syncNekoIdleCat1Journey(button, tier) {
29782983
if (!button) return;
29792984
if (_isNekoIdleCompactSurfaceDragging()) return;
2985+
const initialContainer = _getNekoIdleReturnContainerFromButton(button);
2986+
const initialDragging = initialContainer && initialContainer.getAttribute('data-dragging');
2987+
if (initialDragging && initialDragging !== 'false') return;
29802988
const normalizedTier = _normalizeNekoIdleReturnTier(tier || button.getAttribute('data-neko-idle-tier'));
29812989
const profile = _getNekoIdleReturnSubactionProfile(normalizedTier);
29822990
const state = _getNekoIdleReturnSubactionState(button, profile);
@@ -3911,9 +3919,39 @@ const AvatarButtonMixin = {
39113919
ManagerPrototype._setupReturnButtonDrag = function(container) {
39123920
let isDragging = false;
39133921
let dragActiveDispatched = false;
3922+
let dragSafetyTimer = 0;
3923+
let dragSafetyToken = 0;
39143924
let dragStartX = 0, dragStartY = 0, containerStartX = 0, containerStartY = 0;
39153925

3926+
const clearDragSafetyTimer = () => {
3927+
if (!dragSafetyTimer) return;
3928+
clearTimeout(dragSafetyTimer);
3929+
dragSafetyTimer = 0;
3930+
};
3931+
3932+
const finishDragState = (moved) => {
3933+
container.setAttribute('data-dragging', 'false');
3934+
if (moved) {
3935+
_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-end', {
3936+
movedDistancePx: Math.hypot(
3937+
(parseFloat(container.style.left) || containerStartX) - containerStartX,
3938+
(parseFloat(container.style.top) || containerStartY) - containerStartY
3939+
)
3940+
});
3941+
}
3942+
};
3943+
3944+
const resetDragStateAfterMissingEnd = (safetyToken) => {
3945+
if (dragSafetyToken !== safetyToken || !isDragging) return;
3946+
const moved = container.getAttribute('data-dragging') === 'true';
3947+
isDragging = false;
3948+
dragActiveDispatched = false;
3949+
container.style.cursor = 'grab';
3950+
finishDragState(moved);
3951+
};
3952+
39163953
const handleStart = (clientX, clientY) => {
3954+
clearDragSafetyTimer();
39173955
_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-start');
39183956
isDragging = true;
39193957
dragActiveDispatched = false;
@@ -3927,8 +3965,14 @@ const AvatarButtonMixin = {
39273965
container.style.bottom = '';
39283966
container.style.left = `${containerStartX}px`;
39293967
container.style.top = `${containerStartY}px`;
3930-
container.setAttribute('data-dragging', 'false');
3968+
container.setAttribute('data-dragging', 'pending');
39313969
container.style.cursor = 'grabbing';
3970+
const safetyToken = dragSafetyToken + 1;
3971+
dragSafetyToken = safetyToken;
3972+
dragSafetyTimer = setTimeout(() => {
3973+
dragSafetyTimer = 0;
3974+
resetDragStateAfterMissingEnd(safetyToken);
3975+
}, 5000);
39323976
};
39333977

39343978
const handleMove = (clientX, clientY) => {
@@ -3949,21 +3993,14 @@ const AvatarButtonMixin = {
39493993
};
39503994

39513995
const handleEnd = () => {
3996+
clearDragSafetyTimer();
39523997
if (isDragging) {
39533998
const moved = container.getAttribute('data-dragging') === 'true';
39543999
isDragging = false;
39554000
dragActiveDispatched = false;
39564001
container.style.cursor = 'grab';
39574002
setTimeout(() => {
3958-
container.setAttribute('data-dragging', 'false');
3959-
if (moved) {
3960-
_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-end', {
3961-
movedDistancePx: Math.hypot(
3962-
(parseFloat(container.style.left) || containerStartX) - containerStartX,
3963-
(parseFloat(container.style.top) || containerStartY) - containerStartY
3964-
)
3965-
});
3966-
}
4003+
finishDragState(moved);
39674004
}, 10);
39684005
}
39694006
};

tests/unit/test_avatar_return_button_idle_tiers_static.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@
3434
CAT_MODEL_CHANGE_ASSET_PATH = PROJECT_ROOT / "static" / "assets" / "neko-idle" / "cat_model_change.gif"
3535

3636

37+
def _source_slice_between(source, start_marker, end_marker, block_name):
38+
start = source.find(start_marker)
39+
assert start != -1, f"{block_name} start marker not found: {start_marker}"
40+
end = source.find(end_marker, start + len(start_marker))
41+
assert end != -1, f"{block_name} end marker not found after start: {end_marker}"
42+
assert start < end, f"{block_name} start marker must precede end marker"
43+
return source[start:end]
44+
45+
46+
def _assert_source_contains(block, expected, block_name):
47+
assert expected in block, f"{block_name} missing expected source: {expected}"
48+
49+
50+
def _assert_source_order(block, block_name, *expected_markers):
51+
positions = []
52+
for marker in expected_markers:
53+
position = block.find(marker)
54+
assert position != -1, f"{block_name} missing expected source: {marker}"
55+
positions.append(position)
56+
assert positions == sorted(positions), f"{block_name} expected order: {' -> '.join(expected_markers)}"
57+
58+
3759
def test_return_button_idle_tier_assets_are_mapped_in_source():
3860
source = AVATAR_UI_BUTTONS_PATH.read_text(encoding="utf-8")
3961
app_ui_source = APP_UI_PATH.read_text(encoding="utf-8")
@@ -497,6 +519,139 @@ def test_cat1_walk_to_minimized_chat_contract_is_present():
497519
assert "if (!window.__NEKO_MULTI_WINDOW__)" in source
498520

499521

522+
def test_cat1_walk_is_blocked_while_return_ball_drag_is_active_or_pending():
523+
source = AVATAR_UI_BUTTONS_PATH.read_text(encoding="utf-8")
524+
525+
drag_setup = _source_slice_between(
526+
source,
527+
"ManagerPrototype._setupReturnButtonDrag = function(container) {",
528+
"container.addEventListener('mousedown'",
529+
"return button drag setup",
530+
)
531+
handle_start = _source_slice_between(
532+
source,
533+
"const handleStart = (clientX, clientY) => {",
534+
"const handleMove = (clientX, clientY) => {",
535+
"return button drag start handler",
536+
)
537+
handle_end = _source_slice_between(
538+
source,
539+
"const handleEnd = () => {",
540+
"container.addEventListener('mousedown'",
541+
"return button drag end handler",
542+
)
543+
544+
for expected in (
545+
"let dragSafetyTimer = 0;",
546+
"let dragSafetyToken = 0;",
547+
"const clearDragSafetyTimer = () => {",
548+
"const resetDragStateAfterMissingEnd = (safetyToken) => {",
549+
"if (dragSafetyToken !== safetyToken || !isDragging) return;",
550+
"const finishDragState = (moved) => {",
551+
"container.setAttribute('data-dragging', 'false');",
552+
"_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-end'",
553+
):
554+
_assert_source_contains(drag_setup, expected, "return button drag setup")
555+
_assert_source_order(
556+
drag_setup,
557+
"return button drag setup helpers",
558+
"const finishDragState = (moved) => {",
559+
"const resetDragStateAfterMissingEnd = (safetyToken) => {",
560+
"finishDragState(moved);",
561+
)
562+
_assert_source_contains(
563+
handle_start,
564+
"container.setAttribute('data-dragging', 'pending')",
565+
"return button drag start handler",
566+
)
567+
_assert_source_contains(handle_start, "const safetyToken = dragSafetyToken + 1", "return button drag start handler")
568+
_assert_source_contains(handle_start, "dragSafetyTimer = setTimeout(() => {", "return button drag start handler")
569+
_assert_source_contains(
570+
handle_start,
571+
"resetDragStateAfterMissingEnd(safetyToken);",
572+
"return button drag start handler",
573+
)
574+
_assert_source_contains(handle_start, "}, 5000);", "return button drag start handler")
575+
_assert_source_order(
576+
handle_start,
577+
"return button drag start handler",
578+
"clearDragSafetyTimer();",
579+
"container.setAttribute('data-dragging', 'pending')",
580+
"dragSafetyTimer = setTimeout(() => {",
581+
)
582+
_assert_source_contains(handle_end, "clearDragSafetyTimer();", "return button drag end handler")
583+
_assert_source_contains(
584+
handle_end,
585+
"finishDragState(moved);",
586+
"return button drag end handler",
587+
)
588+
_assert_source_order(
589+
handle_end,
590+
"return button drag end handler",
591+
"clearDragSafetyTimer();",
592+
"if (isDragging) {",
593+
"finishDragState(moved);",
594+
)
595+
596+
sync_block = _source_slice_between(
597+
source,
598+
"function _syncNekoIdleCat1Journey",
599+
"function _scheduleNekoIdleCat1JourneySync(button)",
600+
"cat1 journey sync",
601+
)
602+
for expected in (
603+
"const initialContainer = _getNekoIdleReturnContainerFromButton(button)",
604+
"const initialDragging = initialContainer && initialContainer.getAttribute('data-dragging')",
605+
"if (initialDragging && initialDragging !== 'false') return",
606+
):
607+
_assert_source_contains(sync_block, expected, "cat1 journey sync")
608+
_assert_source_order(
609+
sync_block,
610+
"cat1 journey sync drag guard",
611+
"if (_isNekoIdleCompactSurfaceDragging()) return",
612+
"if (initialDragging && initialDragging !== 'false') return",
613+
"const normalizedTier",
614+
)
615+
616+
container_observer = _source_slice_between(
617+
source,
618+
"state.containerObserver = new MutationObserver(() => {",
619+
"state.containerObserver.observe(container",
620+
"cat1 container observer",
621+
)
622+
_assert_source_contains(
623+
container_observer,
624+
"const observerDragging = container.getAttribute('data-dragging');",
625+
"cat1 container observer",
626+
)
627+
_assert_source_contains(
628+
container_observer,
629+
"if (observerDragging && observerDragging !== 'false') return;",
630+
"cat1 container observer",
631+
)
632+
633+
walk_start = _source_slice_between(
634+
source,
635+
"function _startNekoIdleCat1Walk",
636+
"function _scheduleNekoIdleCat1WalkStart",
637+
"cat1 walk start",
638+
)
639+
for expected in (
640+
"if (_isNekoIdleReturnDragActionActive(button)) return",
641+
"const walkContainer = _getNekoIdleReturnContainerFromButton(button)",
642+
"const walkDragging = walkContainer && walkContainer.getAttribute('data-dragging')",
643+
"if (walkDragging && walkDragging !== 'false') return",
644+
):
645+
_assert_source_contains(walk_start, expected, "cat1 walk start")
646+
_assert_source_order(
647+
walk_start,
648+
"cat1 walk start drag guard",
649+
"if (!state) return",
650+
"if (_isNekoIdleReturnDragActionActive(button)) return",
651+
"const profile = state.profile",
652+
)
653+
654+
500655
def test_return_button_idle_tier_assets_are_version_tracked():
501656
for path in (APP_UI_PATH, APP_INTERPAGE_PATH, COMMON_UI_HUD_PATH,
502657
APP_REACT_CHAT_WINDOW_PATH,

0 commit comments

Comments
 (0)