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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user