From fd1aac3074379563e2fe8b179d4c23730dd47557 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:44:06 +0000 Subject: [PATCH] fix(reader): preserve valid EPUB side clicks Evaluate the long-hold threshold when the pointer is released so the double-click delay does not discard valid page turns. Closes #1992 --- .../__tests__/useFoliateInput.spec.ts | 47 +++++++++++++++++++ .../epub/composables/useFoliateInput.ts | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/client/src/features/reader/epub/composables/__tests__/useFoliateInput.spec.ts b/client/src/features/reader/epub/composables/__tests__/useFoliateInput.spec.ts index a1375bbff..2c7ca4af8 100644 --- a/client/src/features/reader/epub/composables/__tests__/useFoliateInput.spec.ts +++ b/client/src/features/reader/epub/composables/__tests__/useFoliateInput.spec.ts @@ -551,6 +551,53 @@ describe('useFoliateInput', () => { } }) + it.each([ + { holdMs: 250, expectedNavigationCount: 1 }, + { holdMs: 500, expectedNavigationCount: 0 }, + ])('handles a $holdMs ms pointer hold before the delayed click-zone action', ({ holdMs, expectedNavigationCount }) => { + vi.useFakeTimers() + + const next = vi.fn<() => void>() + const view: ViewLike = { + prev: vi.fn<() => void>(), + next, + getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect, + } + const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>()) + const doc = makeDocTarget() + input.attachIframeClicks(doc) + + const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints') + const originalOntouchstart = Object.getOwnPropertyDescriptor(window, 'ontouchstart') + Object.defineProperty(navigator, 'maxTouchPoints', { + configurable: true, + get: () => 0, + }) + Reflect.deleteProperty(window as unknown as Record, 'ontouchstart') + + const postMessage = vi.spyOn(window, 'postMessage').mockImplementation(() => {}) + + doc.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + vi.advanceTimersByTime(holdMs) + doc.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 95, clientY: 100 })) + const message = postMessage.mock.calls[0]?.[0] + if (message) window.dispatchEvent(new MessageEvent('message', { data: message, origin: window.location.origin })) + vi.advanceTimersByTime(300) + + expect(postMessage).toHaveBeenCalledTimes(expectedNavigationCount) + expect(next).toHaveBeenCalledTimes(expectedNavigationCount) + + postMessage.mockRestore() + input.cleanup() + + if (originalMaxTouchPoints) { + Object.defineProperty(navigator, 'maxTouchPoints', originalMaxTouchPoints) + } + if (originalOntouchstart) { + Object.defineProperty(window, 'ontouchstart', originalOntouchstart) + } + }) + it('ignores a page-turn click message that arrives after annotation tap suppression starts', () => { vi.useFakeTimers() diff --git a/client/src/features/reader/epub/composables/useFoliateInput.ts b/client/src/features/reader/epub/composables/useFoliateInput.ts index 8326db6d0..a47f9a44a 100644 --- a/client/src/features/reader/epub/composables/useFoliateInput.ts +++ b/client/src/features/reader/epub/composables/useFoliateInput.ts @@ -200,6 +200,7 @@ export function useFoliateInput( 'click', (e: MouseEvent) => { if (Date.now() - lastTouchTime < 500) return + if (!longHoldTimeout) return const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null if (!iframe) return const rect = iframe.getBoundingClientRect() @@ -264,7 +265,6 @@ export function useFoliateInput( setTimeout(() => { if (isTapNavigationSuppressed()) return if (Date.now() - lastClickTime < DOUBLE_CLICK_MS) return - if (!longHoldTimeout) return if (isNavigating) return const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0