feat: add mobile presenter gestures
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"@fontsource-variable/source-sans-3": "5.2.9",
|
"@fontsource-variable/source-sans-3": "5.2.9",
|
||||||
"@fontsource/barlow-condensed": "5.2.8",
|
"@fontsource/barlow-condensed": "5.2.8",
|
||||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"@xyflow/react": "12.11.1",
|
"@xyflow/react": "12.11.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -34,11 +34,20 @@ export const PresenterRoute = () => {
|
|||||||
const currentKey = navigation.note ? `${navigation.note.sceneId}/${navigation.note.beatId}` : null;
|
const currentKey = navigation.note ? `${navigation.note.sceneId}/${navigation.note.beatId}` : null;
|
||||||
const discussion = navigation.location.kind === "discussion" ? findDiscussionBranch(navigation.location.branchId) : undefined;
|
const discussion = navigation.location.kind === "discussion" ? findDiscussionBranch(navigation.location.branchId) : undefined;
|
||||||
|
|
||||||
|
const nextNote = navigation.next;
|
||||||
|
const previousNote = navigation.previous;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PresenterShell
|
<PresenterShell
|
||||||
current={navigation.note}
|
current={navigation.note}
|
||||||
covered={covered}
|
covered={covered}
|
||||||
activeDiscussionId={navigation.location.kind === "discussion" ? navigation.location.branchId : null}
|
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 && (
|
{navigation.note && (
|
||||||
<PresenterNavigationBar
|
<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 { PresenterBeatNote } from "./presenter-notes.js";
|
||||||
import type { DiscussionBranchId } from "../storyboard.js";
|
import type { DiscussionBranchId } from "../storyboard.js";
|
||||||
import { PresenterSidebar } from "./PresenterSidebar.js";
|
import { PresenterSidebar } from "./PresenterSidebar.js";
|
||||||
@@ -7,12 +8,71 @@ type PresenterShellProps = {
|
|||||||
readonly current: PresenterBeatNote | null;
|
readonly current: PresenterBeatNote | null;
|
||||||
readonly covered: ReadonlySet<string>;
|
readonly covered: ReadonlySet<string>;
|
||||||
readonly activeDiscussionId: DiscussionBranchId | null;
|
readonly activeDiscussionId: DiscussionBranchId | null;
|
||||||
|
readonly onSwipeNext?: (() => void) | undefined;
|
||||||
|
readonly onSwipePrevious?: (() => void) | undefined;
|
||||||
readonly children: ReactNode;
|
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">
|
<main className="presenter-route" aria-label="lda.chat presenter notes">
|
||||||
<PresenterSidebar current={current} covered={covered} activeDiscussionId={activeDiscussionId} />
|
<PresenterSidebar
|
||||||
<div className="presenter-route__reader">{children}</div>
|
current={current}
|
||||||
|
covered={covered}
|
||||||
|
activeDiscussionId={activeDiscussionId}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
{...bindSwipe()}
|
||||||
|
ref={readerRef}
|
||||||
|
className="presenter-route__reader"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
.presenter-note__goal { margin: 2rem 0 1.35rem; }
|
.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 > 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__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 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 { color: #20201e; font: 0.82rem/1.4 "IBM Plex Mono", monospace; }
|
||||||
.presenter-note__anchors li::before { margin-right: 0.45rem; color: #1e6b55; content: "•"; }
|
.presenter-note__anchors li::before { margin-right: 0.45rem; color: #1e6b55; content: "•"; }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1.5rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -256,6 +256,7 @@ tbody tr:hover {
|
|||||||
grid-template-columns: minmax(24rem, 0.9fr) minmax(30rem, 1.1fr);
|
grid-template-columns: minmax(24rem, 0.9fr) minmax(30rem, 1.1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout > section[aria-label="Connection"] {
|
.app-layout > section[aria-label="Connection"] {
|
||||||
|
|||||||
Generated
+18
@@ -35,6 +35,9 @@ importers:
|
|||||||
'@fontsource/ibm-plex-mono':
|
'@fontsource/ibm-plex-mono':
|
||||||
specifier: 5.2.7
|
specifier: 5.2.7
|
||||||
version: 5.2.7
|
version: 5.2.7
|
||||||
|
'@use-gesture/react':
|
||||||
|
specifier: ^10.3.1
|
||||||
|
version: 10.3.1([email protected])
|
||||||
'@xyflow/react':
|
'@xyflow/react':
|
||||||
specifier: 12.11.1
|
specifier: 12.11.1
|
||||||
version: 12.11.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
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]':
|
'@ungap/[email protected]':
|
||||||
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
|
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]':
|
'@vitejs/[email protected]':
|
||||||
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -3993,6 +4004,13 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/[email protected]': {}
|
'@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]))':
|
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user