diff --git a/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts b/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts index 3ec0a10f..6d29606f 100644 --- a/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts +++ b/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts @@ -47,7 +47,7 @@ describe('iframeEventHandlers click gestures', () => { // First click of the double-click: full down/up/click cycle. handleMousedown('book-1', mouseEvent()); handleMouseup('book-1', mouseEvent()); - handleClick('book-1', doubleClickDisabled, mouseEvent()); + handleClick('book-1', doubleClickDisabled, false, mouseEvent()); // The second click begins shortly after and is HELD while the user drags // to extend the native word selection — so only mousedown fires, no @@ -69,7 +69,7 @@ describe('iframeEventHandlers click gestures', () => { handleMousedown('book-1', mouseEvent()); handleMouseup('book-1', mouseEvent()); - handleClick('book-1', doubleClickDisabled, mouseEvent()); + handleClick('book-1', doubleClickDisabled, false, mouseEvent()); vi.advanceTimersByTime(260); @@ -83,13 +83,13 @@ describe('iframeEventHandlers click gestures', () => { // First click. handleMousedown('book-1', mouseEvent()); handleMouseup('book-1', mouseEvent()); - handleClick('book-1', doubleClickDisabled, mouseEvent()); + handleClick('book-1', doubleClickDisabled, false, mouseEvent()); // Second click lands quickly (no drag): a complete down/up/click cycle. vi.advanceTimersByTime(100); handleMousedown('book-1', mouseEvent()); handleMouseup('book-1', mouseEvent()); - handleClick('book-1', doubleClickDisabled, mouseEvent()); + handleClick('book-1', doubleClickDisabled, false, mouseEvent()); vi.advanceTimersByTime(260); @@ -98,3 +98,96 @@ describe('iframeEventHandlers click gestures', () => { expect(types).not.toContain('iframe-single-click'); }); }); + +describe('single-tap opens image gallery / table zoom in reflowable books (#4584)', () => { + let postSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + postSpy = vi.spyOn(window, 'postMessage').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const postedMessages = (): Record[] => + postSpy.mock.calls.map((c: unknown[]) => c[0] as Record); + + // A single tap = a full down/up/click cycle, then advance past the + // double-click window so the deferred single-click logic runs. + const tap = ( + handlers: Awaited>, + isFixedLayout: boolean, + target: EventTarget | null, + ) => { + const { handleClick, handleMousedown, handleMouseup } = handlers; + const doubleClickDisabled = { current: false }; + handleMousedown('book-1', mouseEvent()); + handleMouseup('book-1', mouseEvent()); + handleClick('book-1', doubleClickDisabled, isFixedLayout, mouseEvent({ target })); + vi.advanceTimersByTime(260); + }; + + test('reflowable: tap on an image posts iframe-open-media (image), not iframe-single-click', async () => { + const handlers = await importHandlers(); + const img = document.createElement('img'); + img.src = 'blob:http://localhost/abc'; + + tap(handlers, false, img); + + const messages = postedMessages(); + const types = messages.map((m) => m['type']); + expect(types).toContain('iframe-open-media'); + expect(types).not.toContain('iframe-single-click'); + const media = messages.find((m) => m['type'] === 'iframe-open-media')!; + expect(media['elementType']).toBe('image'); + expect(media['src']).toBe(img.src); + }); + + test('reflowable: tap on a table posts iframe-open-media (table), not iframe-single-click', async () => { + const handlers = await importHandlers(); + const table = document.createElement('table'); + const cell = document.createElement('td'); + table.appendChild(cell); + + tap(handlers, false, cell); // tap lands inside the table + + const messages = postedMessages(); + const types = messages.map((m) => m['type']); + expect(types).toContain('iframe-open-media'); + expect(types).not.toContain('iframe-single-click'); + const media = messages.find((m) => m['type'] === 'iframe-open-media')!; + expect(media['elementType']).toBe('table'); + expect(media['html']).toBe(table.outerHTML); + }); + + test('fixed-layout: tap on an image still posts iframe-single-click (tap turns page)', async () => { + const handlers = await importHandlers(); + const img = document.createElement('img'); + img.src = 'blob:http://localhost/abc'; + + tap(handlers, true, img); + + const types = postedMessages().map((m) => m['type']); + expect(types).toContain('iframe-single-click'); + expect(types).not.toContain('iframe-open-media'); + }); + + test('reflowable: tap on a linked image follows the link (posts neither)', async () => { + const handlers = await importHandlers(); + const anchor = document.createElement('a'); + anchor.href = 'https://example.com'; + const img = document.createElement('img'); + img.src = 'blob:http://localhost/abc'; + anchor.appendChild(img); + + tap(handlers, false, img); + + const types = postedMessages().map((m) => m['type']); + expect(types).not.toContain('iframe-open-media'); + expect(types).not.toContain('iframe-single-click'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index a70e1d1b..160d5f79 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -13,7 +13,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useCustomFontStore } from '@/store/customFontStore'; import { useParallelViewStore } from '@/store/parallelViewStore'; -import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents'; +import { useMouseEvent, useTouchEvent, useOpenMediaEvent } from '../hooks/useIframeEvents'; import { useBrightnessGesture } from '../hooks/useBrightnessGesture'; import BrightnessOverlay from './BrightnessOverlay'; import { usePagination, viewPagination } from '../hooks/usePagination'; @@ -381,7 +381,10 @@ const FoliateViewer: React.FC<{ detail.doc.addEventListener('keyup', handleKeyup.bind(null, bookKey)); detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey)); detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey)); - detail.doc.addEventListener('click', handleClick.bind(null, bookKey, doubleClickDisabled)); + detail.doc.addEventListener( + 'click', + handleClick.bind(null, bookKey, doubleClickDisabled, !!bookData?.isFixedLayout), + ); detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey)); detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey)); detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey)); @@ -585,7 +588,7 @@ const FoliateViewer: React.FC<{ setCurrentImageIndex(0); }, []); - useLongPressEvent(bookKey, handleImagePress, handleTablePress); + useOpenMediaEvent(bookKey, handleImagePress, handleTablePress); useFoliateEvents(viewRef.current, { onLoad: docLoadHandler, diff --git a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts index fc64cd73..8c32f07f 100644 --- a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts @@ -77,13 +77,17 @@ export const useMouseEvent = ( }; }; -export const useLongPressEvent = ( +// Opens the image gallery / table zoom viewer when the iframe reports that the +// user activated an image or table — via either a long-press (any book) or a +// single tap (reflowable books). See the `iframe-open-media` producers in +// iframeEventHandlers.ts. +export const useOpenMediaEvent = ( bookKey: string, handleImagePress: (src: string) => void, handleTablePress: (html: string) => void, ) => { - const handleLongPress = (msg: MessageEvent) => { - if (msg.data && msg.data.bookKey === bookKey && msg.data.type === 'iframe-long-press') { + const handleOpenMedia = (msg: MessageEvent) => { + if (msg.data && msg.data.bookKey === bookKey && msg.data.type === 'iframe-open-media') { if (msg.data.elementType === 'image') { handleImagePress(msg.data.src); } else if (msg.data.elementType === 'table') { @@ -93,9 +97,9 @@ export const useLongPressEvent = ( }; useEffect(() => { - window.addEventListener('message', handleLongPress); + window.addEventListener('message', handleOpenMedia); return () => { - window.removeEventListener('message', handleLongPress); + window.removeEventListener('message', handleOpenMedia); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookKey]); diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts index 53096af3..2727b0a7 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -155,9 +155,32 @@ export const handleWheel = (bookKey: string, event: WheelEvent) => { ); }; +// A tappable/long-pressable media element under the pointer, resolved to the +// payload the image gallery / table zoom viewers consume. Shared by the +// long-press path and the single-tap path so the two can't drift. +type MediaTarget = { elementType: 'image'; src: string } | { elementType: 'table'; html: string }; + +const detectMediaTarget = (target: HTMLElement | null): MediaTarget | null => { + if (!target) return null; + if (target.localName === 'img') { + return { elementType: 'image', src: (target as HTMLImageElement).src }; + } + const svgImage = target.closest('svg')?.querySelector('image'); + if (svgImage) { + const href = + svgImage.getAttribute('href') || + svgImage.getAttributeNS('http://www.w3.org/1999/xlink', 'href'); + if (href) return { elementType: 'image', src: href }; + } + const table = target.localName === 'table' ? target : target.closest('table'); + if (table) return { elementType: 'table', html: (table as HTMLElement).outerHTML }; + return null; +}; + export const handleClick = ( bookKey: string, doubleClickDisabled: React.MutableRefObject, + isFixedLayout: boolean, event: MouseEvent, ) => { const now = Date.now(); @@ -234,6 +257,18 @@ export const handleClick = ( return; } + // In reflowable books a single tap on an image/table opens the same viewer + // a long-press does, so the image gallery / table zoom is reachable by both + // gestures (#4584). Fixed-layout books (PDF/comics/manga) keep tap-to-turn, + // since there the tap is the page-turn gesture. + if (!isFixedLayout) { + const media = detectMediaTarget(element); + if (media) { + window.postMessage({ type: 'iframe-open-media', bookKey, ...media }, '*'); + return; + } + } + window.postMessage( { type: 'iframe-single-click', @@ -316,47 +351,9 @@ export const addLongPressListeners = (bookKey: string, doc: Document) => { return; } - if (target.localName === 'img') { - const imgTarget = target as HTMLImageElement; - window.postMessage( - { - type: 'iframe-long-press', - bookKey, - elementType: 'image', - src: imgTarget.src, - }, - '*', - ); - } else if (target.closest('svg')) { - const svg = target.closest('svg')!; - const svgImage = svg.querySelector('image'); - const href = - svgImage?.getAttribute('href') || - svgImage?.getAttributeNS('http://www.w3.org/1999/xlink', 'href'); - if (href) { - window.postMessage( - { - type: 'iframe-long-press', - bookKey, - elementType: 'image', - src: href, - }, - '*', - ); - } - } else if (target.localName === 'table' || target.closest('table')) { - const tableTarget = ( - target.localName === 'table' ? target : target.closest('table') - ) as HTMLTableElement; - window.postMessage( - { - type: 'iframe-long-press', - bookKey, - elementType: 'table', - html: tableTarget.outerHTML, - }, - '*', - ); + const media = detectMediaTarget(target); + if (media) { + window.postMessage({ type: 'iframe-open-media', bookKey, ...media }, '*'); } };