feat: add mobile presenter gestures
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"@fontsource-variable/source-sans-3": "5.2.9",
|
||||
"@fontsource/barlow-condensed": "5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@xyflow/react": "12.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -34,11 +34,20 @@ export const PresenterRoute = () => {
|
||||
const currentKey = navigation.note ? `${navigation.note.sceneId}/${navigation.note.beatId}` : null;
|
||||
const discussion = navigation.location.kind === "discussion" ? findDiscussionBranch(navigation.location.branchId) : undefined;
|
||||
|
||||
const nextNote = navigation.next;
|
||||
const previousNote = navigation.previous;
|
||||
|
||||
return (
|
||||
<PresenterShell
|
||||
current={navigation.note}
|
||||
covered={covered}
|
||||
activeDiscussionId={navigation.location.kind === "discussion" ? navigation.location.branchId : null}
|
||||
onSwipeNext={nextNote
|
||||
? () => { window.location.hash = presenterHashForNote(nextNote); }
|
||||
: undefined}
|
||||
onSwipePrevious={previousNote
|
||||
? () => { window.location.hash = presenterHashForNote(previousNote); }
|
||||
: undefined}
|
||||
>
|
||||
{navigation.note && (
|
||||
<PresenterNavigationBar
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PresenterShell } from "./PresenterShell.js";
|
||||
|
||||
class TestPointerEvent extends MouseEvent {
|
||||
readonly pointerId: number;
|
||||
readonly pointerType: string;
|
||||
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 0;
|
||||
this.pointerType = init.pointerType ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("PointerEvent", TestPointerEvent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const renderShell = ({
|
||||
onSwipeNext,
|
||||
onSwipePrevious,
|
||||
}: {
|
||||
readonly onSwipeNext?: () => void;
|
||||
readonly onSwipePrevious?: () => void;
|
||||
} = {}) =>
|
||||
render(
|
||||
<PresenterShell
|
||||
current={null}
|
||||
covered={new Set()}
|
||||
activeDiscussionId={null}
|
||||
onSwipeNext={onSwipeNext}
|
||||
onSwipePrevious={onSwipePrevious}
|
||||
>
|
||||
<p>Presenter content</p>
|
||||
<a href="#details">Details</a>
|
||||
</PresenterShell>,
|
||||
);
|
||||
|
||||
const swipe = (target: Element, fromX: number, toX: number, fromY = 20, toY = 20) => {
|
||||
fireEvent.pointerDown(target, {
|
||||
buttons: 1, clientX: fromX, clientY: fromY, pointerId: 1, pointerType: "touch",
|
||||
});
|
||||
fireEvent.pointerMove(target, {
|
||||
buttons: 1, clientX: toX, clientY: toY, pointerId: 1, pointerType: "touch",
|
||||
});
|
||||
fireEvent.pointerUp(target, {
|
||||
buttons: 0, clientX: toX, clientY: toY, pointerId: 1, pointerType: "touch",
|
||||
});
|
||||
};
|
||||
|
||||
const reversingSwipe = (target: Element, points: readonly number[]) => {
|
||||
const [start, ...moves] = points;
|
||||
if (start === undefined || moves.length === 0) throw new Error("A swipe needs a start and release point");
|
||||
|
||||
fireEvent.pointerDown(target, {
|
||||
buttons: 1, clientX: start, clientY: 20, pointerId: 1, pointerType: "touch",
|
||||
});
|
||||
for (const clientX of moves) {
|
||||
fireEvent.pointerMove(target, {
|
||||
buttons: 1, clientX, clientY: 20, pointerId: 1, pointerType: "touch",
|
||||
});
|
||||
}
|
||||
fireEvent.pointerUp(target, {
|
||||
buttons: 0, clientX: moves.at(-1), clientY: 20, pointerId: 1, pointerType: "touch",
|
||||
});
|
||||
};
|
||||
|
||||
describe("PresenterShell", () => {
|
||||
it("reveals main content on a fresh page load", () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
vi.spyOn(window, "scrollY", "get").mockReturnValue(0);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
renderShell();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "start" });
|
||||
});
|
||||
|
||||
it("preserves a restored or manual scroll position", () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
vi.spyOn(window, "scrollY", "get").mockReturnValue(240);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
renderShell();
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates with deliberate horizontal touch swipes", () => {
|
||||
const onSwipeNext = vi.fn();
|
||||
const onSwipePrevious = vi.fn();
|
||||
const { container } = renderShell({ onSwipeNext, onSwipePrevious });
|
||||
const reader = container.querySelector(".presenter-route__reader");
|
||||
expect(reader).not.toBeNull();
|
||||
|
||||
swipe(reader!, 180, 100);
|
||||
swipe(reader!, 100, 180);
|
||||
|
||||
expect(onSwipeNext).toHaveBeenCalledTimes(1);
|
||||
expect(onSwipePrevious).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps vertical scrolling and interactive content out of swipe navigation", () => {
|
||||
const onSwipeNext = vi.fn();
|
||||
const { container } = renderShell({ onSwipeNext });
|
||||
const reader = container.querySelector(".presenter-route__reader");
|
||||
const link = container.querySelector("a[href='#details']");
|
||||
expect(reader).not.toBeNull();
|
||||
expect(link).not.toBeNull();
|
||||
|
||||
swipe(reader!, 120, 110, 20, 120);
|
||||
swipe(link!, 180, 100);
|
||||
|
||||
expect(onSwipeNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses final release displacement after a gesture reverses direction", () => {
|
||||
const onSwipeNext = vi.fn();
|
||||
const onSwipePrevious = vi.fn();
|
||||
const { container } = renderShell({ onSwipeNext, onSwipePrevious });
|
||||
const reader = container.querySelector(".presenter-route__reader");
|
||||
expect(reader).not.toBeNull();
|
||||
|
||||
reversingSwipe(reader!, [160, 80, 240]);
|
||||
|
||||
expect(onSwipeNext).not.toHaveBeenCalled();
|
||||
expect(onSwipePrevious).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not navigate when a reversing gesture releases near its origin", () => {
|
||||
const onSwipeNext = vi.fn();
|
||||
const onSwipePrevious = vi.fn();
|
||||
const { container } = renderShell({ onSwipeNext, onSwipePrevious });
|
||||
const reader = container.querySelector(".presenter-route__reader");
|
||||
expect(reader).not.toBeNull();
|
||||
|
||||
reversingSwipe(reader!, [160, 80, 165]);
|
||||
|
||||
expect(onSwipeNext).not.toHaveBeenCalled();
|
||||
expect(onSwipePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import type { PresenterBeatNote } from "./presenter-notes.js";
|
||||
import type { DiscussionBranchId } from "../storyboard.js";
|
||||
import { PresenterSidebar } from "./PresenterSidebar.js";
|
||||
@@ -7,12 +8,71 @@ type PresenterShellProps = {
|
||||
readonly current: PresenterBeatNote | null;
|
||||
readonly covered: ReadonlySet<string>;
|
||||
readonly activeDiscussionId: DiscussionBranchId | null;
|
||||
readonly onSwipeNext?: (() => void) | undefined;
|
||||
readonly onSwipePrevious?: (() => void) | undefined;
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
|
||||
export const PresenterShell = ({ current, covered, activeDiscussionId, children }: 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;
|
||||
|
||||
const RELEASE_DELTA_PX = 50;
|
||||
|
||||
export const PresenterShell = ({
|
||||
current,
|
||||
covered,
|
||||
activeDiscussionId,
|
||||
onSwipeNext,
|
||||
onSwipePrevious,
|
||||
children,
|
||||
}: PresenterShellProps) => {
|
||||
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;
|
||||
if (first) {
|
||||
swipeStartedInInteractiveContent.current = isInteractiveSwipeTarget(event);
|
||||
}
|
||||
if (!last || swipeStartedInInteractiveContent.current) return;
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Let browser scroll restoration win; only skip the mobile index when this
|
||||
// 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" });
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="presenter-route" aria-label="lda.chat presenter notes">
|
||||
<PresenterSidebar current={current} covered={covered} activeDiscussionId={activeDiscussionId} />
|
||||
<div className="presenter-route__reader">{children}</div>
|
||||
<PresenterSidebar
|
||||
current={current}
|
||||
covered={covered}
|
||||
activeDiscussionId={activeDiscussionId}
|
||||
/>
|
||||
<div
|
||||
{...bindSwipe()}
|
||||
ref={readerRef}
|
||||
className="presenter-route__reader"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
.presenter-note__goal { margin: 2rem 0 1.35rem; }
|
||||
.presenter-note__goal > span, .presenter-note__anchors > span, .presenter-note__say > span { display: block; color: #67655f; font: 700 0.8rem "IBM Plex Mono", monospace; text-transform: uppercase; }
|
||||
.presenter-note__goal p { max-width: 58ch; margin: 0.45rem 0 0; color: #20201e; font-size: 1.62rem; font-weight: 650; line-height: 1.25; text-wrap: balance; }
|
||||
.presenter-note__anchors { margin-bottom: 2rem; padding: 0.8rem 0; border-block: 1px solid #d7d5d0; }
|
||||
.presenter-note__anchors { margin-bottom: 2rem; padding-inline: 0.8rem; border-block: 1px solid #d7d5d0; }
|
||||
.presenter-note__anchors ul { display: flex; flex-wrap: wrap; gap: 0.4rem 1.3rem; margin: 0.55rem 0 0; padding: 0; list-style: none; }
|
||||
.presenter-note__anchors li { color: #20201e; font: 0.82rem/1.4 "IBM Plex Mono", monospace; }
|
||||
.presenter-note__anchors li::before { margin-right: 0.45rem; color: #1e6b55; content: "•"; }
|
||||
|
||||
@@ -35,7 +35,7 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -256,6 +256,7 @@ tbody tr:hover {
|
||||
grid-template-columns: minmax(24rem, 0.9fr) minmax(30rem, 1.1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.app-layout > section[aria-label="Connection"] {
|
||||
|
||||
Generated
+18
@@ -35,6 +35,9 @@ importers:
|
||||
'@fontsource/ibm-plex-mono':
|
||||
specifier: 5.2.7
|
||||
version: 5.2.7
|
||||
'@use-gesture/react':
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1([email protected])
|
||||
'@xyflow/react':
|
||||
specifier: 12.11.1
|
||||
version: 12.11.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -1537,6 +1540,14 @@ packages:
|
||||
'@ungap/[email protected]':
|
||||
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
|
||||
|
||||
'@use-gesture/[email protected]':
|
||||
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
|
||||
|
||||
'@use-gesture/[email protected]':
|
||||
resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
|
||||
'@vitejs/[email protected]':
|
||||
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -3993,6 +4004,13 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@use-gesture/[email protected]': {}
|
||||
|
||||
'@use-gesture/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@use-gesture/core': 10.3.1
|
||||
react: 19.2.7
|
||||
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
|
||||
Reference in New Issue
Block a user