e145eb835a
* 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>
194 lines
6.9 KiB
TypeScript
194 lines
6.9 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// The handlers keep module-level state (last click time, long-hold timer,
|
|
// mouse-button state), so each test re-imports the module fresh.
|
|
const importHandlers = () => import('@/app/reader/utils/iframeEventHandlers');
|
|
|
|
function mouseEvent(overrides: Partial<MouseEvent> = {}): MouseEvent {
|
|
return {
|
|
button: 0,
|
|
screenX: 100,
|
|
screenY: 100,
|
|
clientX: 100,
|
|
clientY: 100,
|
|
offsetX: 10,
|
|
offsetY: 10,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
metaKey: false,
|
|
target: null,
|
|
...overrides,
|
|
} as unknown as MouseEvent;
|
|
}
|
|
|
|
function postedTypes(spy: ReturnType<typeof vi.spyOn>): string[] {
|
|
return spy.mock.calls.map((call: unknown[]) => (call[0] as { type: string }).type);
|
|
}
|
|
|
|
describe('iframeEventHandlers click gestures', () => {
|
|
let postSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers();
|
|
postSpy = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test('double-click then drag (button held) does not post iframe-single-click (#4524)', async () => {
|
|
const { handleClick, handleMousedown, handleMouseup } = await importHandlers();
|
|
const doubleClickDisabled = { current: false };
|
|
|
|
// First click of the double-click: full down/up/click cycle.
|
|
handleMousedown('book-1', mouseEvent());
|
|
handleMouseup('book-1', 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
|
|
// mouseup/click yet.
|
|
vi.advanceTimersByTime(110);
|
|
handleMousedown('book-1', mouseEvent());
|
|
|
|
// Advancing past the double-click window fires the first click's deferred
|
|
// single-click timer. With the button still held, it must be suppressed
|
|
// (otherwise the page turns mid-selection).
|
|
vi.advanceTimersByTime(260);
|
|
|
|
expect(postedTypes(postSpy)).not.toContain('iframe-single-click');
|
|
});
|
|
|
|
test('a normal single click still posts iframe-single-click after the threshold', async () => {
|
|
const { handleClick, handleMousedown, handleMouseup } = await importHandlers();
|
|
const doubleClickDisabled = { current: false };
|
|
|
|
handleMousedown('book-1', mouseEvent());
|
|
handleMouseup('book-1', mouseEvent());
|
|
handleClick('book-1', doubleClickDisabled, false, mouseEvent());
|
|
|
|
vi.advanceTimersByTime(260);
|
|
|
|
expect(postedTypes(postSpy)).toContain('iframe-single-click');
|
|
});
|
|
|
|
test('a plain double-click posts iframe-double-click and not iframe-single-click', async () => {
|
|
const { handleClick, handleMousedown, handleMouseup } = await importHandlers();
|
|
const doubleClickDisabled = { current: false };
|
|
|
|
// First click.
|
|
handleMousedown('book-1', mouseEvent());
|
|
handleMouseup('book-1', 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, false, mouseEvent());
|
|
|
|
vi.advanceTimersByTime(260);
|
|
|
|
const types = postedTypes(postSpy);
|
|
expect(types).toContain('iframe-double-click');
|
|
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');
|
|
});
|
|
});
|