fix: stabilize mobile presenter navigation
This commit is contained in:
@@ -44,6 +44,16 @@ describe("PresenterRoute", () => {
|
|||||||
expect(window.location.hash).toBe("#scene/thesis/substrate");
|
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", () => {
|
it("renders Q&A speaker guidance only in presenter mode", () => {
|
||||||
window.location.hash = "#discuss/where-is-ai-agent";
|
window.location.hash = "#discuss/where-is-ai-agent";
|
||||||
render(<PresenterRoute />);
|
render(<PresenterRoute />);
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import "./presenter.css";
|
|||||||
|
|
||||||
const readHash = () => presenterNavigationFromHash(window.location.hash);
|
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 = () => {
|
export const PresenterRoute = () => {
|
||||||
const [navigation, setNavigation] = useState(readHash);
|
const [navigation, setNavigation] = useState(readHash);
|
||||||
const [covered, setCovered] = useState<ReadonlySet<string>>(() => new Set());
|
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;
|
const destination = event.key === "ArrowRight" ? navigation.next : event.key === "ArrowLeft" ? navigation.previous : null;
|
||||||
if (!destination) return;
|
if (!destination) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.location.hash = presenterHashForNote(destination);
|
moveFromCurrentHash(event.key === "ArrowRight" ? "next" : "previous");
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -43,10 +50,10 @@ export const PresenterRoute = () => {
|
|||||||
covered={covered}
|
covered={covered}
|
||||||
activeDiscussionId={navigation.location.kind === "discussion" ? navigation.location.branchId : null}
|
activeDiscussionId={navigation.location.kind === "discussion" ? navigation.location.branchId : null}
|
||||||
onSwipeNext={nextNote
|
onSwipeNext={nextNote
|
||||||
? () => { window.location.hash = presenterHashForNote(nextNote); }
|
? () => { moveFromCurrentHash("next"); }
|
||||||
: undefined}
|
: undefined}
|
||||||
onSwipePrevious={previousNote
|
onSwipePrevious={previousNote
|
||||||
? () => { window.location.hash = presenterHashForNote(previousNote); }
|
? () => { moveFromCurrentHash("previous"); }
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{navigation.note && (
|
{navigation.note && (
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ type PresenterShellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isInteractiveSwipeTarget = (event: Event): boolean =>
|
const isInteractiveSwipeTarget = (event: Event): boolean =>
|
||||||
event.target instanceof Element
|
event.target instanceof Element &&
|
||||||
&& event.target.closest("a, button, input, textarea, select, [role='button'], [contenteditable='true'], pre, code") !== null;
|
event.target.closest(
|
||||||
|
"a, button, input, textarea, select, [role='button'], [contenteditable='true'], pre, code",
|
||||||
|
) !== null;
|
||||||
|
|
||||||
const RELEASE_DELTA_PX = 50;
|
const RELEASE_DELTA_PX = 50;
|
||||||
|
|
||||||
@@ -30,21 +32,31 @@ export const PresenterShell = ({
|
|||||||
const readerRef = useRef<HTMLDivElement>(null);
|
const readerRef = useRef<HTMLDivElement>(null);
|
||||||
const swipeStartedInInteractiveContent = useRef(false);
|
const swipeStartedInInteractiveContent = useRef(false);
|
||||||
const bindSwipe = useDrag(
|
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) {
|
if (first) {
|
||||||
swipeStartedInInteractiveContent.current = isInteractiveSwipeTarget(event);
|
swipeStartedInInteractiveContent.current =
|
||||||
|
isInteractiveSwipeTarget(event);
|
||||||
}
|
}
|
||||||
if (!last || swipeStartedInInteractiveContent.current) return;
|
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?.();
|
if (deltaX < 0) onSwipeNext?.();
|
||||||
else onSwipePrevious?.();
|
else onSwipePrevious?.();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
pointer: { capture: false },
|
pointer: { capture: false },
|
||||||
preventScroll: 0,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,7 +65,10 @@ export const PresenterShell = ({
|
|||||||
// is a genuinely fresh page load that remains at the top.
|
// is a genuinely fresh page load that remains at the top.
|
||||||
const frame = window.requestAnimationFrame(() => {
|
const frame = window.requestAnimationFrame(() => {
|
||||||
if (window.scrollY === 0) {
|
if (window.scrollY === 0) {
|
||||||
readerRef.current?.scrollIntoView?.({ behavior: "auto", block: "start" });
|
readerRef.current?.scrollIntoView?.({
|
||||||
|
behavior: "auto",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => window.cancelAnimationFrame(frame);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
@@ -66,11 +81,7 @@ export const PresenterShell = ({
|
|||||||
covered={covered}
|
covered={covered}
|
||||||
activeDiscussionId={activeDiscussionId}
|
activeDiscussionId={activeDiscussionId}
|
||||||
/>
|
/>
|
||||||
<div
|
<div {...bindSwipe()} ref={readerRef} className="presenter-route__reader">
|
||||||
{...bindSwipe()}
|
|
||||||
ref={readerRef}
|
|
||||||
className="presenter-route__reader"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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: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-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 { 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 { color: #155b49; font-weight: 650; text-decoration: none; }
|
||||||
.presenter-navigation a:last-child, .presenter-navigation > span:last-child { text-align: right; }
|
.presenter-navigation a:last-child, .presenter-navigation > span:last-child { text-align: right; }
|
||||||
|
|||||||
Reference in New Issue
Block a user