fix(rsvp): cross-device resume seeding + mobile slider drag (#4004)

* fix(rsvp): seed local position from synced BookConfig on resume

* refactor(rsvp): simplify seedPosition

Consolidate the matched/mismatched write paths into one localStorage
write, extract stripCfiPath() to a module-level helper, and trim the
comments around it.

* fix(rsvp): make progress bar draggable on mobile

Three coordinated fixes so the RSVP overlay's seek bar works reliably
under touch:

- Add `touch-action: none` on the slider so the mobile browser stops
  claiming the gesture for scroll/pan and firing pointercancel mid-drag.
- Hoist the `.rsvp-controls`/`.rsvp-header` exclusion to the top of the
  overlay's touchend handler so a successful drag isn't immediately
  re-interpreted as a speed-change swipe.
- Guard `releasePointerCapture` with `hasPointerCapture` so pointercancel
  arriving after the browser has already released capture (multitouch,
  app backgrounding) no longer throws NotFoundError.
This commit is contained in:
Lex Moulton
2026-05-01 11:42:58 -04:00
committed by GitHub
parent fb37406b31
commit 293d5b5f5d
5 changed files with 243 additions and 15 deletions
@@ -138,3 +138,59 @@ describe('RSVPOverlay — context panel performance', () => {
expect(current!.getAttribute('role')).toBeNull();
});
});
describe('RSVPOverlay — progress bar drag on mobile', () => {
afterEach(() => cleanup());
test('horizontal drag starting on the progress bar does not trigger a speed swipe', () => {
const words = Array.from({ length: 100 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 10 });
const { container, controller } = renderOverlay(state);
const slider = container.querySelector('[role="slider"]') as HTMLElement;
expect(slider).not.toBeNull();
// Simulate a horizontal touch drag long enough to clear SWIPE_THRESHOLD (50px).
fireEvent.touchStart(slider, { touches: [{ clientX: 50, clientY: 400 }] });
fireEvent.touchEnd(slider, {
changedTouches: [{ clientX: 200, clientY: 400 }],
});
expect(controller.increaseSpeed).not.toHaveBeenCalled();
expect(controller.decreaseSpeed).not.toHaveBeenCalled();
});
test('horizontal drag starting on a footer button does not trigger a speed swipe', () => {
const state = buildState({
words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container, controller } = renderOverlay(state);
// Pick any control inside `.rsvp-controls` (e.g. the play/pause button).
const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement;
expect(playButton).not.toBeNull();
fireEvent.touchStart(playButton, { touches: [{ clientX: 50, clientY: 400 }] });
fireEvent.touchEnd(playButton, {
changedTouches: [{ clientX: 200, clientY: 400 }],
});
expect(controller.increaseSpeed).not.toHaveBeenCalled();
expect(controller.decreaseSpeed).not.toHaveBeenCalled();
});
test('progress bar uses touch-action: none so pointer capture survives on mobile', () => {
const state = buildState({
words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const slider = container.querySelector('[role="slider"]') as HTMLElement;
expect(slider).not.toBeNull();
expect(slider.style.touchAction).toBe('none');
});
});
@@ -1,7 +1,9 @@
import { describe, test, expect, vi } from 'vitest';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { RSVPController } from '@/services/rsvp/RSVPController';
import { FoliateView } from '@/types/view';
const POSITION_KEY = 'readest_rsvp_pos_test';
function makeTextNode(text: string): Text {
return { nodeType: Node.TEXT_NODE, textContent: text } as unknown as Text;
}
@@ -160,6 +162,143 @@ describe('RSVPController', () => {
});
});
describe('seedPosition', () => {
beforeEach(() => {
localStorage.clear();
});
test('overwrites stale local position with cloud-synced position', () => {
// Device B has a stale local entry from a previous session.
const stale = { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'stale' };
localStorage.setItem(POSITION_KEY, JSON.stringify(stale));
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
// Cloud-synced position arrives via BookConfig.rsvpPosition.
const fresh = { cfi: 'epubcfi(/6/8!/4/2/1:0)', wordText: 'fresh' };
controller.seedPosition(fresh);
expect(JSON.parse(localStorage.getItem(POSITION_KEY)!)).toEqual(fresh);
});
test('writes provided position when localStorage is empty', () => {
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const position = { cfi: 'epubcfi(/6/8!/4/2/1:0)', wordText: 'fresh' };
controller.seedPosition(position);
expect(JSON.parse(localStorage.getItem(POSITION_KEY)!)).toEqual(position);
});
test('skips redundant write when value already matches', () => {
const position = { cfi: 'epubcfi(/6/8!/4/2/1:0)', wordText: 'same' };
localStorage.setItem(POSITION_KEY, JSON.stringify(position));
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
controller.seedPosition(position);
const positionWrites = setItemSpy.mock.calls.filter(([key]) => key === POSITION_KEY);
expect(positionWrites).toHaveLength(0);
setItemSpy.mockRestore();
});
test('falls back to start of synced chapter when rsvpPosition is in a different chapter than location', () => {
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const stalePosition = { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'stale' };
const currentLocation = 'epubcfi(/6/8!/4/2/1:0)';
controller.seedPosition(stalePosition, currentLocation);
expect(JSON.parse(localStorage.getItem(POSITION_KEY)!)).toEqual({
cfi: 'epubcfi(/6/8)',
wordText: '',
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[RSVP]'),
expect.objectContaining({ rsvpCfi: stalePosition.cfi, locationCfi: currentLocation }),
);
warnSpy.mockRestore();
});
test('section-start fallback overwrites a stale local entry on chapter mismatch', () => {
const stale = { cfi: 'epubcfi(/6/2!/4/2/1:0)', wordText: 'stale' };
localStorage.setItem(POSITION_KEY, JSON.stringify(stale));
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
controller.seedPosition(
{ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'fresh' },
'epubcfi(/6/8!/4/2/1:0)',
);
expect(JSON.parse(localStorage.getItem(POSITION_KEY)!)).toEqual({
cfi: 'epubcfi(/6/8)',
wordText: '',
});
warnSpy.mockRestore();
});
test('skips redundant write when section-start fallback already matches stored value', () => {
const fallback = { cfi: 'epubcfi(/6/8)', wordText: '' };
localStorage.setItem(POSITION_KEY, JSON.stringify(fallback));
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
controller.seedPosition(
{ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'fresh' },
'epubcfi(/6/8!/4/2/1:0)',
);
const positionWrites = setItemSpy.mock.calls.filter(([key]) => key === POSITION_KEY);
expect(positionWrites).toHaveLength(0);
setItemSpy.mockRestore();
warnSpy.mockRestore();
});
test('seeds normally when rsvpPosition and location share a spine section', () => {
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const position = { cfi: 'epubcfi(/6/8!/4/2/1:0)', wordText: 'fresh' };
const currentLocation = 'epubcfi(/6/8!/4/2/3:5)'; // same spine, different offset
controller.seedPosition(position, currentLocation);
expect(JSON.parse(localStorage.getItem(POSITION_KEY)!)).toEqual(position);
});
test('seeds normally when no current location is provided', () => {
const doc = makeDoc('hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
const position = { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'fresh' };
controller.seedPosition(position);
expect(JSON.parse(localStorage.getItem(POSITION_KEY)!)).toEqual(position);
});
});
describe('duplicate word blank insertion', () => {
test('inserts blank between two consecutive identical words', () => {
const doc = makeDoc('the the cat');
@@ -230,11 +230,13 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
});
}
// Seed localStorage from cloud-synced BookConfig when this device has no local position.
// This restores RSVP progress saved on another device after a sync.
const configPos = getConfig(bookKey)?.rsvpPosition;
// Seed localStorage from cloud-synced BookConfig so a fresh cross-device
// rsvpPosition can override a stale local entry. seedPosition guards against
// a corrupt synced pair (rsvpPosition.cfi in a different chapter than location).
const config = getConfig(bookKey);
const configPos = config?.rsvpPosition;
if (configPos) {
controller.seedPosition(configPos);
controller.seedPosition(configPos, config?.location ?? progress?.location ?? null);
}
// Set current CFI for position tracking
@@ -350,6 +350,14 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const handleTouchEnd = (event: React.TouchEvent) => {
if (event.changedTouches.length !== 1) return;
// Touches starting on the header or footer controls (progress bar, buttons,
// dropdowns) own their own gestures — never let a horizontal drag here be
// hijacked as a speed-change swipe, or a tap as a region tap.
const target = event.target as HTMLElement;
if (target.closest('.rsvp-controls') || target.closest('.rsvp-header')) {
return;
}
const touch = event.changedTouches[0]!;
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
@@ -365,11 +373,6 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
if (Math.abs(deltaX) < TAP_THRESHOLD && Math.abs(deltaY) < TAP_THRESHOLD && duration < 300) {
const target = event.target as HTMLElement;
if (target.closest('.rsvp-controls') || target.closest('.rsvp-header')) {
return;
}
const screenWidth = window.innerWidth;
const tapX = touch.clientX;
@@ -455,7 +458,12 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
if (!isDraggingProgressBar.current) return;
isDraggingProgressBar.current = false;
setIsProgressBarDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
// pointercancel can fire after the browser has already released the
// capture itself (e.g. multitouch, app backgrounding), so calling
// releasePointerCapture unconditionally would throw NotFoundError.
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (wasPlayingBeforeDrag.current) setTimeout(() => controller.resume(), 50);
};
@@ -748,6 +756,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
aria-valuemin={0}
aria-valuemax={100}
className='relative h-2 cursor-pointer overflow-visible rounded bg-gray-500/30'
// touch-action: none keeps mobile browsers from claiming the
// gesture for scroll/pan, which would fire pointercancel and
// break the drag-to-seek pointer capture mid-gesture.
style={{ touchAction: 'none' }}
onPointerDown={handleProgressBarPointerDown}
onPointerMove={handleProgressBarPointerMove}
onPointerUp={handleProgressBarPointerUp}
@@ -16,6 +16,9 @@ const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
const SPLIT_HYPHENS_KEY = 'readest_rsvp_split_hyphens';
// Section-only CFI (no '!') sorts before any word CFI in that section.
const stripCfiPath = (cfi: string): string => cfi.replace(/!.*\)$/, ')');
export class RSVPController extends EventTarget {
private view: FoliateView;
private bookId: string; // Book hash without session suffix, for persistent storage
@@ -218,11 +221,27 @@ export class RSVPController extends EventTarget {
localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookId}`);
}
seedPosition(position: RsvpPosition): void {
const storageKey = `${POSITION_KEY_PREFIX}${this.bookId}`;
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, JSON.stringify(position));
seedPosition(position: RsvpPosition, currentLocationCfi?: string | null): void {
const key = `${POSITION_KEY_PREFIX}${this.bookId}`;
let final = position;
// Cross-chapter mismatch means stale sync (exit pins them together);
// fall back to the start of the location's chapter.
if (
currentLocationCfi &&
position.cfi &&
!this.isSameSection(position.cfi, currentLocationCfi)
) {
console.warn('[RSVP] rsvpPosition chapter mismatch; resetting to start of synced chapter', {
rsvpCfi: position.cfi,
locationCfi: currentLocationCfi,
});
final = { cfi: stripCfiPath(currentLocationCfi), wordText: '' };
}
const serialized = JSON.stringify(final);
if (localStorage.getItem(key) === serialized) return;
localStorage.setItem(key, serialized);
}
getStoredPosition(): RsvpPosition | null {