fix: stabilize mobile presenter navigation

This commit is contained in:
lda
2026-07-14 01:11:04 +07:00 Verified
parent 24f9af6a5c
commit 52e184a72b
4 changed files with 46 additions and 18 deletions
@@ -44,6 +44,16 @@ describe("PresenterRoute", () => {
expect(window.location.hash).toBe("#scene/thesis/substrate");
});
it("advances from the latest hash during rapid consecutive navigation", () => {
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
fireEvent.keyDown(window, { key: "ArrowRight" });
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(window.location.hash).toBe("#scene/problem/direct-actions");
});
it("renders Q&A speaker guidance only in presenter mode", () => {
window.location.hash = "#discuss/where-is-ai-agent";
render(<PresenterRoute />);
@@ -9,6 +9,13 @@ import "./presenter.css";
const readHash = () => presenterNavigationFromHash(window.location.hash);
const moveFromCurrentHash = (direction: "next" | "previous") => {
// Gesture and key events can arrive before hashchange rerenders this route.
// Resolve from the URL so rapid inputs advance instead of repeating one hop.
const destination = presenterNavigationFromHash(window.location.hash)[direction];
if (destination) window.location.hash = presenterHashForNote(destination);
};
export const PresenterRoute = () => {
const [navigation, setNavigation] = useState(readHash);
const [covered, setCovered] = useState<ReadonlySet<string>>(() => new Set());
@@ -25,7 +32,7 @@ export const PresenterRoute = () => {
const destination = event.key === "ArrowRight" ? navigation.next : event.key === "ArrowLeft" ? navigation.previous : null;
if (!destination) return;
event.preventDefault();
window.location.hash = presenterHashForNote(destination);
moveFromCurrentHash(event.key === "ArrowRight" ? "next" : "previous");
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
@@ -43,10 +50,10 @@ export const PresenterRoute = () => {
covered={covered}
activeDiscussionId={navigation.location.kind === "discussion" ? navigation.location.branchId : null}
onSwipeNext={nextNote
? () => { window.location.hash = presenterHashForNote(nextNote); }
? () => { moveFromCurrentHash("next"); }
: undefined}
onSwipePrevious={previousNote
? () => { window.location.hash = presenterHashForNote(previousNote); }
? () => { moveFromCurrentHash("previous"); }
: undefined}
>
{navigation.note && (
@@ -14,8 +14,10 @@ type PresenterShellProps = {
};
const isInteractiveSwipeTarget = (event: Event): boolean =>
event.target instanceof Element
&& event.target.closest("a, button, input, textarea, select, [role='button'], [contenteditable='true'], pre, code") !== null;
event.target instanceof Element &&
event.target.closest(
"a, button, input, textarea, select, [role='button'], [contenteditable='true'], pre, code",
) !== null;
const RELEASE_DELTA_PX = 50;
@@ -30,21 +32,31 @@ export const PresenterShell = ({
const readerRef = useRef<HTMLDivElement>(null);
const swipeStartedInInteractiveContent = useRef(false);
const bindSwipe = useDrag(
({ event, first, last, movement: [deltaX, deltaY] }) => {
if (!(event instanceof PointerEvent) || event.pointerType !== "touch") return;
({
event,
first,
last,
movement: [deltaX, deltaY],
}) => {
if (!(event instanceof PointerEvent) || event.pointerType !== "touch")
return;
if (first) {
swipeStartedInInteractiveContent.current = isInteractiveSwipeTarget(event);
swipeStartedInInteractiveContent.current =
isInteractiveSwipeTarget(event);
}
if (!last || swipeStartedInInteractiveContent.current) return;
if (Math.abs(deltaX) < RELEASE_DELTA_PX || Math.abs(deltaX) <= Math.abs(deltaY)) return;
// threshold + 45deg angle check
if (
Math.abs(deltaX) < RELEASE_DELTA_PX ||
Math.abs(deltaX) <= Math.abs(deltaY)
)
return;
if (deltaX < 0) onSwipeNext?.();
else onSwipePrevious?.();
},
{
filterTaps: true,
pointer: { capture: false },
preventScroll: 0,
},
);
@@ -53,7 +65,10 @@ export const PresenterShell = ({
// is a genuinely fresh page load that remains at the top.
const frame = window.requestAnimationFrame(() => {
if (window.scrollY === 0) {
readerRef.current?.scrollIntoView?.({ behavior: "auto", block: "start" });
readerRef.current?.scrollIntoView?.({
behavior: "auto",
block: "start",
});
}
});
return () => window.cancelAnimationFrame(frame);
@@ -66,11 +81,7 @@ export const PresenterShell = ({
covered={covered}
activeDiscussionId={activeDiscussionId}
/>
<div
{...bindSwipe()}
ref={readerRef}
className="presenter-route__reader"
>
<div {...bindSwipe()} ref={readerRef} className="presenter-route__reader">
{children}
</div>
</main>
@@ -36,7 +36,7 @@
.presenter-sidebar__qna a:hover, .presenter-sidebar__qna a:focus-visible { color: #155b49; text-decoration: underline; text-underline-offset: 0.15em; }
.presenter-sidebar__qna a[aria-current="page"] { color: #155b49; font-weight: 700; }
.presenter-route__reader { min-width: 0; padding: 1.25rem clamp(1.5rem, 5vw, 6rem) 5rem; }
.presenter-route__reader { min-width: 0; padding: 1.25rem clamp(1.5rem, 5vw, 6rem) 5rem; touch-action: pan-y; }
.presenter-navigation { max-width: 72ch; min-height: 2.5rem; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; margin: 0 auto 2rem; border-bottom: 1px solid #d7d5d0; color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
.presenter-navigation a { color: #155b49; font-weight: 650; text-decoration: none; }
.presenter-navigation a:last-child, .presenter-navigation > span:last-child { text-align: right; }