diff --git a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx index 2f6d1058..972f0d17 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx @@ -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'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts index ec753ae9..ba13ae90 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -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'); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index 3b794cb2..93415d05 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -230,11 +230,13 @@ const RSVPControl: React.FC = ({ 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 diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 3a87d568..0f339124 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -350,6 +350,14 @@ const RSVPOverlay: React.FC = ({ 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 = ({ } 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 = ({ 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 = ({ 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} diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts index 268fd569..ce5072b9 100644 --- a/apps/readest-app/src/services/rsvp/RSVPController.ts +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -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 {