forked from akai/readest
f5e729a174
The smooth-wheel feature (#3974, closing #3966) intercepts mouse-wheel events in scroll mode: it makes the wheel listener non-passive, preventDefault()s the native scroll, and replays the delta through a main-thread rAF animation against the renderer container. That regressed normal mouse scrolling on Windows (#4130): fast wheel bursts were discarded entirely, and the JS replay is structurally worse than native scrolling -- a non-passive wheel listener forces every wheel event (mouse and trackpad) off the compositor thread, and the postMessage hop plus main-thread animation add latency and jank that native compositor scrolling does not have. High-resolution scrolling (e.g. Logitech MX Master, the mouse in #3966) needs no special API: the OS/driver just delivers regular wheel events with smaller, more frequent deltas, and the browser scrolls them natively. #3966's own report ("smooth scrolling works with all applications apart from yours") points at the interception, not a missing capability. Restore native wheel scrolling in scroll mode. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
import { DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, LONG_HOLD_THRESHOLD } from '@/services/constants';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
|
|
let lastClickTime = 0;
|
|
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
let keyboardState = {
|
|
key: '',
|
|
code: '',
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
metaKey: false,
|
|
};
|
|
|
|
const getKeyStatus = (event?: MouseEvent | WheelEvent | TouchEvent) => {
|
|
if (event && 'ctrlKey' in event) {
|
|
return {
|
|
key: keyboardState.key,
|
|
code: keyboardState.code,
|
|
ctrlKey: event.ctrlKey,
|
|
shiftKey: event.shiftKey,
|
|
altKey: event.altKey,
|
|
metaKey: event.metaKey,
|
|
};
|
|
}
|
|
return {
|
|
...keyboardState,
|
|
};
|
|
};
|
|
|
|
export const handleKeydown = (bookKey: string, event: KeyboardEvent) => {
|
|
keyboardState = {
|
|
key: event.key,
|
|
code: event.code,
|
|
ctrlKey: event.ctrlKey,
|
|
shiftKey: event.shiftKey,
|
|
altKey: event.altKey,
|
|
metaKey: event.metaKey,
|
|
};
|
|
|
|
if (['Backspace'].includes(event.key)) {
|
|
event.preventDefault();
|
|
}
|
|
if (event.ctrlKey && event.key.toLowerCase() === 'f') {
|
|
event.preventDefault();
|
|
}
|
|
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-keydown',
|
|
bookKey,
|
|
key: event.key,
|
|
code: event.code,
|
|
ctrlKey: event.ctrlKey,
|
|
shiftKey: event.shiftKey,
|
|
altKey: event.altKey,
|
|
metaKey: event.metaKey,
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
|
|
export const handleKeyup = (bookKey: string, event: KeyboardEvent) => {
|
|
keyboardState = {
|
|
key: '',
|
|
code: '',
|
|
ctrlKey: event.ctrlKey,
|
|
shiftKey: event.shiftKey,
|
|
altKey: event.altKey,
|
|
metaKey: event.metaKey,
|
|
};
|
|
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-keyup',
|
|
bookKey,
|
|
key: event.key,
|
|
code: event.code,
|
|
ctrlKey: event.ctrlKey,
|
|
shiftKey: event.shiftKey,
|
|
altKey: event.altKey,
|
|
metaKey: event.metaKey,
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
|
|
export const handleMousedown = (bookKey: string, event: MouseEvent) => {
|
|
longHoldTimeout = setTimeout(() => {
|
|
longHoldTimeout = null;
|
|
}, LONG_HOLD_THRESHOLD);
|
|
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-mousedown',
|
|
bookKey,
|
|
button: event.button,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
offsetX: event.offsetX,
|
|
offsetY: event.offsetY,
|
|
...getKeyStatus(event),
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
|
|
export const handleMouseup = (bookKey: string, event: MouseEvent) => {
|
|
// we will handle mouse back and forward buttons ourselves
|
|
if ([3, 4].includes(event.button)) {
|
|
event.preventDefault();
|
|
}
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-mouseup',
|
|
bookKey,
|
|
button: event.button,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
offsetX: event.offsetX,
|
|
offsetY: event.offsetY,
|
|
...getKeyStatus(event),
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
|
|
export const handleWheel = (bookKey: string, event: WheelEvent) => {
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-wheel',
|
|
bookKey,
|
|
deltaMode: event.deltaMode,
|
|
deltaX: event.deltaX,
|
|
deltaY: event.deltaY,
|
|
deltaZ: event.deltaZ,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
offsetX: event.offsetX,
|
|
offsetY: event.offsetY,
|
|
...getKeyStatus(event),
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
|
|
export const handleClick = (
|
|
bookKey: string,
|
|
doubleClickDisabled: React.MutableRefObject<boolean>,
|
|
event: MouseEvent,
|
|
) => {
|
|
const now = Date.now();
|
|
|
|
if (!doubleClickDisabled.current && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
|
lastClickTime = now;
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-double-click',
|
|
bookKey,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
offsetX: event.offsetX,
|
|
offsetY: event.offsetY,
|
|
...getKeyStatus(event),
|
|
},
|
|
'*',
|
|
);
|
|
return;
|
|
}
|
|
|
|
lastClickTime = now;
|
|
|
|
const postSingleClick = () => {
|
|
const element = event.target as HTMLElement | null;
|
|
if (
|
|
element?.closest('sup, a, audio, video') &&
|
|
!element?.closest('a.duokan-footnote:not([href])')
|
|
) {
|
|
return;
|
|
}
|
|
const footnoteSelector = [
|
|
'.js_readerFooterNote',
|
|
'.zhangyue-footnote',
|
|
'.duokan-footnote',
|
|
'.qqreader-footnote',
|
|
].join(', ');
|
|
const footnote = element?.closest(footnoteSelector);
|
|
if (footnote) {
|
|
eventDispatcher.dispatch('footnote-popup', {
|
|
bookKey,
|
|
element: footnote,
|
|
footnote:
|
|
footnote.getAttribute('data-wr-footernote') ||
|
|
footnote.getAttribute('zy-footnote') ||
|
|
footnote.querySelector('img')?.getAttribute('alt') ||
|
|
footnote.getAttribute('alt') ||
|
|
element?.getAttribute('alt') ||
|
|
'',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// if long hold is detected, we don't want to send single click event
|
|
if (!longHoldTimeout) {
|
|
return;
|
|
}
|
|
|
|
window.postMessage(
|
|
{
|
|
type: 'iframe-single-click',
|
|
bookKey,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
offsetX: event.offsetX,
|
|
offsetY: event.offsetY,
|
|
...getKeyStatus(event),
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
if (!doubleClickDisabled.current) {
|
|
setTimeout(() => {
|
|
if (Date.now() - lastClickTime >= DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
|
postSingleClick();
|
|
}
|
|
}, DOUBLE_CLICK_INTERVAL_THRESHOLD_MS);
|
|
} else {
|
|
postSingleClick();
|
|
}
|
|
};
|
|
|
|
const handleTouchEv = (bookKey: string, event: TouchEvent, type: string) => {
|
|
// Use event.touches (all active touches) instead of event.targetTouches
|
|
// so that multi-finger gestures work even when fingers land on different
|
|
// elements within the iframe (e.g. canvas vs textLayer spans in PDF)
|
|
const touchList = type === 'iframe-touchend' ? event.targetTouches : event.touches;
|
|
const touches = [];
|
|
for (let i = 0; i < touchList.length; i++) {
|
|
const touch = touchList[i];
|
|
if (touch) {
|
|
touches.push({
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
screenX: touch.screenX,
|
|
screenY: touch.screenY,
|
|
});
|
|
}
|
|
}
|
|
window.postMessage(
|
|
{
|
|
type: type,
|
|
bookKey,
|
|
timeStamp: Date.now(),
|
|
targetTouches: touches,
|
|
...getKeyStatus(event),
|
|
},
|
|
'*',
|
|
);
|
|
};
|
|
|
|
export const handleTouchStart = (bookKey: string, event: TouchEvent) => {
|
|
handleTouchEv(bookKey, event, 'iframe-touchstart');
|
|
};
|
|
|
|
export const handleTouchMove = (bookKey: string, event: TouchEvent) => {
|
|
handleTouchEv(bookKey, event, 'iframe-touchmove');
|
|
};
|
|
|
|
export const handleTouchEnd = (bookKey: string, event: TouchEvent) => {
|
|
handleTouchEv(bookKey, event, 'iframe-touchend');
|
|
};
|
|
|
|
export const addLongPressListeners = (bookKey: string, doc: Document) => {
|
|
const longPressDuration = 500;
|
|
const moveThreshold = 10; // pixels - movement threshold to detect dragging/selection
|
|
const pressTimers = new Map<Element, ReturnType<typeof setTimeout>>();
|
|
const pressStartPositions = new Map<Element, { x: number; y: number }>();
|
|
|
|
const handleLongPress = (event: Event, target: HTMLElement) => {
|
|
event.preventDefault?.();
|
|
|
|
// Check if there's an active text selection - if so, don't trigger long-press
|
|
const selection = doc.getSelection();
|
|
if (selection && selection.toString().length > 0) {
|
|
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 startPress = (event: Event) => {
|
|
const target = event.target as HTMLElement;
|
|
const isImage = target.localName === 'img';
|
|
const isSvgImage = !isImage && !!target.closest('svg')?.querySelector('image');
|
|
const isTableOrInTable = target.localName === 'table' || target.closest('table');
|
|
|
|
if (!isImage && !isSvgImage && !isTableOrInTable) return;
|
|
|
|
const elementToTrack = isImage
|
|
? target
|
|
: isSvgImage
|
|
? (target.closest('svg') as unknown as HTMLElement)
|
|
: ((target.localName === 'table' ? target : target.closest('table')) as HTMLElement);
|
|
|
|
// Store initial position for movement detection
|
|
if ('clientX' in event && 'clientY' in event) {
|
|
const mouseEvent = event as MouseEvent;
|
|
pressStartPositions.set(elementToTrack, { x: mouseEvent.clientX, y: mouseEvent.clientY });
|
|
} else if ('touches' in event) {
|
|
const touchEvent = event as TouchEvent;
|
|
const touch = touchEvent.touches[0];
|
|
if (touch) {
|
|
pressStartPositions.set(elementToTrack, { x: touch.clientX, y: touch.clientY });
|
|
}
|
|
}
|
|
|
|
clearTimeout(pressTimers.get(elementToTrack));
|
|
const timer = setTimeout(() => handleLongPress(event, elementToTrack), longPressDuration);
|
|
pressTimers.set(elementToTrack, timer);
|
|
};
|
|
|
|
const handleMove = (event: Event) => {
|
|
const target = event.target as HTMLElement;
|
|
const isImage = target.localName === 'img';
|
|
const isSvgImage = !isImage && !!target.closest('svg')?.querySelector('image');
|
|
const isTableOrInTable = target.localName === 'table' || target.closest('table');
|
|
|
|
if (!isImage && !isSvgImage && !isTableOrInTable) return;
|
|
|
|
const elementToTrack = isImage
|
|
? target
|
|
: isSvgImage
|
|
? (target.closest('svg') as unknown as HTMLElement)
|
|
: ((target.localName === 'table' ? target : target.closest('table')) as HTMLElement);
|
|
|
|
// Check if mouse/touch moved beyond threshold - if so, user is probably selecting text or dragging
|
|
const startPos = pressStartPositions.get(elementToTrack);
|
|
if (startPos) {
|
|
let currentX = 0;
|
|
let currentY = 0;
|
|
|
|
if ('clientX' in event && 'clientY' in event) {
|
|
const mouseEvent = event as MouseEvent;
|
|
currentX = mouseEvent.clientX;
|
|
currentY = mouseEvent.clientY;
|
|
} else if ('touches' in event) {
|
|
const touchEvent = event as TouchEvent;
|
|
const touch = touchEvent.touches[0];
|
|
if (touch) {
|
|
currentX = touch.clientX;
|
|
currentY = touch.clientY;
|
|
}
|
|
}
|
|
|
|
const distance = Math.sqrt(
|
|
Math.pow(currentX - startPos.x, 2) + Math.pow(currentY - startPos.y, 2),
|
|
);
|
|
|
|
// If moved beyond threshold, cancel the long-press
|
|
if (distance > moveThreshold) {
|
|
clearTimeout(pressTimers.get(elementToTrack));
|
|
pressTimers.delete(elementToTrack);
|
|
pressStartPositions.delete(elementToTrack);
|
|
}
|
|
}
|
|
};
|
|
|
|
const cancelPress = (event: Event) => {
|
|
const target = event.target as HTMLElement;
|
|
const isImage = target.localName === 'img';
|
|
const isSvgImage = !isImage && !!target.closest('svg')?.querySelector('image');
|
|
const isTableOrInTable = target.localName === 'table' || target.closest('table');
|
|
|
|
if (!isImage && !isSvgImage && !isTableOrInTable) return;
|
|
|
|
const elementToTrack = isImage
|
|
? target
|
|
: isSvgImage
|
|
? (target.closest('svg') as unknown as HTMLElement)
|
|
: ((target.localName === 'table' ? target : target.closest('table')) as HTMLElement);
|
|
|
|
clearTimeout(pressTimers.get(elementToTrack));
|
|
pressTimers.delete(elementToTrack);
|
|
pressStartPositions.delete(elementToTrack);
|
|
};
|
|
|
|
const processElements = () => {
|
|
const addLongPressListeners = (el: Element) => {
|
|
if (el.hasAttribute('data-long-press-added')) return;
|
|
el.setAttribute('data-long-press-added', 'true');
|
|
el.addEventListener('mousedown', startPress);
|
|
el.addEventListener('mousemove', handleMove);
|
|
el.addEventListener('mouseup', cancelPress);
|
|
el.addEventListener('mouseleave', cancelPress);
|
|
el.addEventListener('touchstart', startPress, { passive: true });
|
|
el.addEventListener('touchmove', handleMove, { passive: true });
|
|
el.addEventListener('touchend', cancelPress);
|
|
};
|
|
|
|
doc.querySelectorAll('img, table').forEach(addLongPressListeners);
|
|
doc.querySelectorAll('svg').forEach((svg) => {
|
|
if (svg.querySelector('image')) addLongPressListeners(svg);
|
|
});
|
|
};
|
|
|
|
processElements();
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
const hasNewElements = mutations.some((m) => m.type === 'childList' && m.addedNodes.length > 0);
|
|
if (hasNewElements) {
|
|
processElements();
|
|
}
|
|
});
|
|
|
|
observer.observe(doc.body, { childList: true, subtree: true });
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
pressTimers.forEach((timer) => clearTimeout(timer));
|
|
};
|
|
};
|