fix: stabilize mobile presenter navigation
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user