Skip to content

Commit 023bd0b

Browse files
author
lijiahao
committed
Optimize gamepad navigation
1 parent 956d459 commit 023bd0b

7 files changed

Lines changed: 342 additions & 428 deletions

File tree

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { useRouter } from "next/router";
2+
import { ReactNode, useEffect, useRef } from "react";
3+
import { FOCUS_ELEMS } from "../common/constans";
4+
5+
type Direction = "up" | "down" | "left" | "right";
6+
type GamepadAction = Direction | "click";
7+
8+
type Candidate = {
9+
element: HTMLElement;
10+
rect: DOMRect;
11+
centerX: number;
12+
centerY: number;
13+
};
14+
15+
const EXTRA_FOCUS_ELEMS = [
16+
'[role="button"]',
17+
'[role="checkbox"]',
18+
'[role="combobox"]',
19+
'[role="radio"]',
20+
'[role="slider"]',
21+
'[role="switch"]',
22+
'[role="tab"]',
23+
'[role="menuitem"]',
24+
'[role="option"]',
25+
'[data-focusable="true"]',
26+
].join(", ");
27+
28+
const FOCUSABLE_SELECTOR = `${FOCUS_ELEMS}, ${EXTRA_FOCUS_ELEMS}`;
29+
const OVERLAY_SELECTOR = '[role="dialog"], [role="menu"], [role="listbox"]';
30+
const DISABLED_PAGES = new Set(["stream", "test", "nativeTest", "map"]);
31+
32+
const INITIAL_REPEAT_DELAY_MS = 280;
33+
const REPEAT_INTERVAL_MS = 130;
34+
const AXIS_PRESS_THRESHOLD = 0.55;
35+
const AXIS_RELEASE_THRESHOLD = 0.35;
36+
37+
const isHTMLElement = (value: unknown): value is HTMLElement => {
38+
return value instanceof HTMLElement;
39+
};
40+
41+
const isVisible = (element: HTMLElement) => {
42+
const rect = element.getBoundingClientRect();
43+
const style = window.getComputedStyle(element);
44+
return (
45+
rect.width > 0 &&
46+
rect.height > 0 &&
47+
style.visibility !== "hidden" &&
48+
style.display !== "none"
49+
);
50+
};
51+
52+
const isFocusable = (element: HTMLElement) => {
53+
if (!isVisible(element)) {
54+
return false;
55+
}
56+
57+
if (element.closest("[aria-hidden='true'], [inert]")) {
58+
return false;
59+
}
60+
61+
if (
62+
element.hasAttribute("disabled") ||
63+
element.getAttribute("aria-disabled") === "true"
64+
) {
65+
return false;
66+
}
67+
68+
return true;
69+
};
70+
71+
const getCandidates = (root: ParentNode): Candidate[] => {
72+
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR))
73+
.filter(isHTMLElement)
74+
.filter(isFocusable)
75+
.map((element) => {
76+
const rect = element.getBoundingClientRect();
77+
return {
78+
element,
79+
rect,
80+
centerX: rect.left + rect.width / 2,
81+
centerY: rect.top + rect.height / 2,
82+
};
83+
});
84+
};
85+
86+
const getActiveScope = () => {
87+
const activeElement = document.activeElement;
88+
if (isHTMLElement(activeElement)) {
89+
const activeOverlay = activeElement.closest(OVERLAY_SELECTOR);
90+
if (activeOverlay && isHTMLElement(activeOverlay) && isVisible(activeOverlay)) {
91+
return activeOverlay;
92+
}
93+
}
94+
95+
const overlays = Array.from(document.querySelectorAll(OVERLAY_SELECTOR))
96+
.filter(isHTMLElement)
97+
.filter(isVisible)
98+
.filter((overlay) => getCandidates(overlay).length > 0);
99+
100+
return overlays[overlays.length - 1] || document.body;
101+
};
102+
103+
const isSameElement = (a: HTMLElement, b: HTMLElement) => a === b;
104+
105+
const sortByVisualOrder = (items: Candidate[]) => {
106+
return [...items].sort((a, b) => {
107+
const verticalDelta = a.rect.top - b.rect.top;
108+
if (Math.abs(verticalDelta) > 8) {
109+
return verticalDelta;
110+
}
111+
return a.rect.left - b.rect.left;
112+
});
113+
};
114+
115+
const overlaps = (aStart: number, aEnd: number, bStart: number, bEnd: number) => {
116+
return Math.max(aStart, bStart) <= Math.min(aEnd, bEnd);
117+
};
118+
119+
const isInDirection = (from: Candidate, to: Candidate, direction: Direction) => {
120+
switch (direction) {
121+
case "up":
122+
return to.centerY < from.centerY - 4;
123+
case "down":
124+
return to.centerY > from.centerY + 4;
125+
case "left":
126+
return to.centerX < from.centerX - 4;
127+
case "right":
128+
return to.centerX > from.centerX + 4;
129+
}
130+
};
131+
132+
const scoreCandidate = (from: Candidate, to: Candidate, direction: Direction) => {
133+
const isVertical = direction === "up" || direction === "down";
134+
const primaryDistance = isVertical
135+
? Math.abs(to.centerY - from.centerY)
136+
: Math.abs(to.centerX - from.centerX);
137+
const perpendicularDistance = isVertical
138+
? Math.abs(to.centerX - from.centerX)
139+
: Math.abs(to.centerY - from.centerY);
140+
const hasOverlap = isVertical
141+
? overlaps(from.rect.left, from.rect.right, to.rect.left, to.rect.right)
142+
: overlaps(from.rect.top, from.rect.bottom, to.rect.top, to.rect.bottom);
143+
144+
return primaryDistance * 3 + perpendicularDistance + (hasOverlap ? 0 : 220);
145+
};
146+
147+
const findNextCandidate = (
148+
candidates: Candidate[],
149+
activeElement: HTMLElement | null,
150+
direction: Direction
151+
) => {
152+
if (candidates.length < 1) {
153+
return null;
154+
}
155+
156+
if (!activeElement) {
157+
return sortByVisualOrder(candidates)[0];
158+
}
159+
160+
const current =
161+
candidates.find((candidate) => isSameElement(candidate.element, activeElement)) ||
162+
candidates.find((candidate) => candidate.element.contains(activeElement));
163+
164+
if (!current) {
165+
return sortByVisualOrder(candidates)[0];
166+
}
167+
168+
return candidates
169+
.filter((candidate) => !isSameElement(candidate.element, current.element))
170+
.filter((candidate) => isInDirection(current, candidate, direction))
171+
.sort((a, b) => scoreCandidate(current, a, direction) - scoreCandidate(current, b, direction))[0] || null;
172+
};
173+
174+
const focusElement = (element: HTMLElement) => {
175+
document.documentElement.classList.add("gamepad-navigation-active");
176+
element.focus({ preventScroll: true });
177+
element.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
178+
};
179+
180+
const clickElement = (element: HTMLElement) => {
181+
document.documentElement.classList.add("gamepad-navigation-active");
182+
element.click();
183+
};
184+
185+
const getPrimaryGamepad = () => {
186+
const gamepads = navigator.getGamepads();
187+
for (let index = 0; index < gamepads.length; index++) {
188+
if (gamepads[index]?.connected) {
189+
return gamepads[index];
190+
}
191+
}
192+
return null;
193+
};
194+
195+
const getAxisDirection = (axisValue: number, negative: Direction, positive: Direction) => {
196+
if (axisValue <= -AXIS_PRESS_THRESHOLD) {
197+
return negative;
198+
}
199+
if (axisValue >= AXIS_PRESS_THRESHOLD) {
200+
return positive;
201+
}
202+
return null;
203+
};
204+
205+
const getAction = (gamepad: Gamepad, lastAxisAction: Direction | null): GamepadAction | null => {
206+
if (gamepad.buttons[0]?.pressed) {
207+
return "click";
208+
}
209+
if (gamepad.buttons[12]?.pressed) {
210+
return "up";
211+
}
212+
if (gamepad.buttons[13]?.pressed) {
213+
return "down";
214+
}
215+
if (gamepad.buttons[14]?.pressed) {
216+
return "left";
217+
}
218+
if (gamepad.buttons[15]?.pressed) {
219+
return "right";
220+
}
221+
222+
const xAxis = Number(gamepad.axes[0]) || 0;
223+
const yAxis = Number(gamepad.axes[1]) || 0;
224+
if (Math.abs(xAxis) < AXIS_RELEASE_THRESHOLD && Math.abs(yAxis) < AXIS_RELEASE_THRESHOLD) {
225+
return null;
226+
}
227+
228+
const dominantDirection = Math.abs(xAxis) > Math.abs(yAxis)
229+
? getAxisDirection(xAxis, "left", "right")
230+
: getAxisDirection(yAxis, "up", "down");
231+
232+
return dominantDirection || lastAxisAction;
233+
};
234+
235+
const getPageName = (pathname: string) => {
236+
const parts = pathname.split("/").filter(Boolean);
237+
return parts[parts.length - 1] || "";
238+
};
239+
240+
export default function GamepadNavigationProvider({ children }: { children: ReactNode }) {
241+
const router = useRouter();
242+
const heldActionRef = useRef<GamepadAction | null>(null);
243+
const lastAxisActionRef = useRef<Direction | null>(null);
244+
const nextRepeatAtRef = useRef(0);
245+
const rafRef = useRef<number | null>(null);
246+
247+
useEffect(() => {
248+
const clearInputState = () => {
249+
heldActionRef.current = null;
250+
lastAxisActionRef.current = null;
251+
nextRepeatAtRef.current = 0;
252+
};
253+
254+
const handlePointerInput = () => {
255+
document.documentElement.classList.remove("gamepad-navigation-active");
256+
};
257+
258+
window.addEventListener("mousedown", handlePointerInput, true);
259+
window.addEventListener("touchstart", handlePointerInput, true);
260+
window.addEventListener("keydown", handlePointerInput, true);
261+
262+
const runAction = (action: GamepadAction) => {
263+
const scope = getActiveScope();
264+
const candidates = getCandidates(scope);
265+
const activeElement = isHTMLElement(document.activeElement)
266+
? document.activeElement
267+
: null;
268+
269+
if (action === "click") {
270+
const currentCandidate = activeElement
271+
? candidates.find((candidate) => candidate.element === activeElement) ||
272+
candidates.find((candidate) => candidate.element.contains(activeElement))
273+
: null;
274+
const target = currentCandidate?.element || sortByVisualOrder(candidates)[0]?.element;
275+
if (target) {
276+
clickElement(target);
277+
}
278+
return;
279+
}
280+
281+
const nextCandidate = findNextCandidate(candidates, activeElement, action);
282+
if (nextCandidate) {
283+
focusElement(nextCandidate.element);
284+
}
285+
};
286+
287+
const update = () => {
288+
const pageName = getPageName(router.pathname);
289+
if (DISABLED_PAGES.has(pageName)) {
290+
clearInputState();
291+
rafRef.current = window.requestAnimationFrame(update);
292+
return;
293+
}
294+
295+
const gamepad = getPrimaryGamepad();
296+
const action = gamepad ? getAction(gamepad, lastAxisActionRef.current) : null;
297+
const now = performance.now();
298+
299+
if (!action) {
300+
clearInputState();
301+
} else if (action !== heldActionRef.current) {
302+
runAction(action);
303+
heldActionRef.current = action;
304+
lastAxisActionRef.current = action === "click" ? null : action;
305+
nextRepeatAtRef.current = now + INITIAL_REPEAT_DELAY_MS;
306+
} else if (action !== "click" && now >= nextRepeatAtRef.current) {
307+
runAction(action);
308+
nextRepeatAtRef.current = now + REPEAT_INTERVAL_MS;
309+
}
310+
311+
rafRef.current = window.requestAnimationFrame(update);
312+
};
313+
314+
rafRef.current = window.requestAnimationFrame(update);
315+
316+
return () => {
317+
window.removeEventListener("mousedown", handlePointerInput, true);
318+
window.removeEventListener("touchstart", handlePointerInput, true);
319+
window.removeEventListener("keydown", handlePointerInput, true);
320+
if (rafRef.current !== null) {
321+
window.cancelAnimationFrame(rafRef.current);
322+
}
323+
};
324+
}, [router.pathname]);
325+
326+
return <>{children}</>;
327+
}

0 commit comments

Comments
 (0)