feat(reader): open image gallery & table zoom on single tap (#4600)
* feat(reader): open image gallery & table zoom on single tap In reflowable EPUBs, a single tap on an image or table now opens the same viewer a long-press opens, so the image gallery / table zoom is reachable by both gestures. Fixed-layout books (PDF/comics/manga) keep tap-to-turn, and long-press is unchanged everywhere. Reuses the existing iframe-long-press -> handleImagePress/handleTablePress flow via a new shared detectMediaTarget() helper (also adopted by the long-press path so the two entry points can't drift). handleClick now takes an isFixedLayout flag; the tap branch sits after the link/footnote/drag/ long-hold/Word-Wise guards so linked images still follow links and a long-hold or double-tap won't double-trigger. Context: #4584 (single taps stop registering after picture zoom on some WebView builds) - this adds a second, independent way into the viewer rather than fixing that root cause. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(reader): rename iframe-long-press message to iframe-open-media The message is now posted for both a long-press (any book) and a single tap on an image/table (reflowable books), so the long-press-specific name was misleading. Rename the message type to `iframe-open-media` and the consumer hook `useLongPressEvent` -> `useOpenMediaEvent`. The long-press detector (`addLongPressListeners`/`handleLongPress`) keeps its name since it still detects a long-press specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
postSpy = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const postedMessages = (): Record<string, unknown>[] =>
|
||||
postSpy.mock.calls.map((c: unknown[]) => c[0] as Record<string, unknown>);
|
||||
|
||||
// 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<ReturnType<typeof importHandlers>>,
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<boolean>,
|
||||
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 }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user