On the web, double-clicking a word and then dragging to extend the native selection also turned the page. The first click's deferred single-click timer fires 250ms later while the second click's button is still held during the drag, so it posts iframe-single-click and flips the page. A plain double-click escapes this because its fast second click updates lastClickTime in time. Track the mouse-button state in iframeEventHandlers and suppress the deferred single click while the button is held (a drag is in progress). A normal single click is unaffected: its button is already released by the time the deferred timer fires. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
|
||||
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
|
||||
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
|
||||
- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners)
|
||||
|
||||
## Library Fixes
|
||||
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: dblclick-drag-pageturn-4524
|
||||
description: Web double-click-and-drag selection turned the page; deferred single-click fired mid-drag while button held
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 5fe20151-9768-4e7c-9cee-2aa25da5318c
|
||||
---
|
||||
|
||||
#4524: on Readest Web, **double-click a word then drag** to extend the native
|
||||
selection also **turned the page** (a plain double-click did not). The user
|
||||
expects browser-native double-click-drag word-by-word selection without a page
|
||||
turn.
|
||||
|
||||
**Root cause** (`src/app/reader/utils/iframeEventHandlers.ts` `handleClick`):
|
||||
the first click of a potential double-click schedules a deferred
|
||||
`postSingleClick()` after `DOUBLE_CLICK_INTERVAL_THRESHOLD_MS` (250ms).
|
||||
- Plain double-click: the 2nd `click` fires fast, updates `lastClickTime`, posts
|
||||
`iframe-double-click`; when the 1st click's timer fires, the
|
||||
`Date.now() - lastClickTime >= 250` check is now false → single-click
|
||||
suppressed → no page turn.
|
||||
- Double-click **+ drag**: the user holds the button down on the 2nd click and
|
||||
drags, so the 2nd `mouseup`/`click` is delayed past 250ms. At first-click+250ms
|
||||
`lastClickTime` is still the 1st click → check passes → `iframe-single-click`
|
||||
posted **while the button is still held** → `usePagination.handlePageFlip`
|
||||
turns the page.
|
||||
|
||||
**Fix**: module-level `isMouseDown` flag (set in `handleMousedown`, cleared in
|
||||
`handleMouseup`); the deferred `postSingleClick()` returns early when
|
||||
`isMouseDown` is true (a drag is in progress). Cannot affect a normal single
|
||||
click — `isMouseDown` is false by the time its deferred timer fires; only a
|
||||
held button (drag) suppresses it.
|
||||
|
||||
**Verification gotcha**: reproduced live by dispatching synthetic
|
||||
mousedown/mouseup/click to the reading iframe doc (found via deep shadow-DOM
|
||||
walk; the foliate iframe sits in nested shadow roots, `document.querySelectorAll('iframe')`
|
||||
returns 0). Watch `iframe-single-click` on `window` 'message' + the
|
||||
`.progress-info-label` "N / M" page text. NOTE: back-to-back synthetic gestures
|
||||
share the module's real `setTimeout` deferrals and `lastClickTime`, so a
|
||||
follow-up "normal single click" repro can spuriously show no single-click — the
|
||||
vitest unit test (fake timers) is the authoritative regression check, not
|
||||
chained browser repros. Iframe listeners are attached once
|
||||
(`detail.doc.isEventListenersAdded`), so a full page reload is required to pick
|
||||
up edits — Fast Refresh won't re-bind them.
|
||||
|
||||
Test: `src/__tests__/reader/utils/iframeEventHandlers.test.ts`. Related:
|
||||
[[foliate-touch-listener-capture-phase]], [[progressbar-focus-ring-4397]].
|
||||
@@ -0,0 +1,100 @@
|
||||
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, 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, 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, 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());
|
||||
|
||||
vi.advanceTimersByTime(260);
|
||||
|
||||
const types = postedTypes(postSpy);
|
||||
expect(types).toContain('iframe-double-click');
|
||||
expect(types).not.toContain('iframe-single-click');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
let lastClickTime = 0;
|
||||
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMouseDown = false;
|
||||
|
||||
let keyboardState = {
|
||||
key: '',
|
||||
@@ -87,6 +88,7 @@ export const handleKeyup = (bookKey: string, event: KeyboardEvent) => {
|
||||
};
|
||||
|
||||
export const handleMousedown = (bookKey: string, event: MouseEvent) => {
|
||||
isMouseDown = true;
|
||||
longHoldTimeout = setTimeout(() => {
|
||||
longHoldTimeout = null;
|
||||
}, LONG_HOLD_THRESHOLD);
|
||||
@@ -109,6 +111,7 @@ export const handleMousedown = (bookKey: string, event: MouseEvent) => {
|
||||
};
|
||||
|
||||
export const handleMouseup = (bookKey: string, event: MouseEvent) => {
|
||||
isMouseDown = false;
|
||||
// we will handle mouse back and forward buttons ourselves
|
||||
if ([3, 4].includes(event.button)) {
|
||||
event.preventDefault();
|
||||
@@ -209,6 +212,13 @@ export const handleClick = (
|
||||
return;
|
||||
}
|
||||
|
||||
// if the mouse button is still held, a drag is in progress (e.g. a
|
||||
// double-click-and-drag selection); sending a single click here would turn
|
||||
// the page mid-selection (#4524).
|
||||
if (isMouseDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if long hold is detected, we don't want to send single click event
|
||||
if (!longHoldTimeout) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user