Add single click/tap to turn page

This commit is contained in:
chrox
2024-11-15 19:30:13 +01:00
parent 2e6a1de36b
commit bc4b203464
4 changed files with 227 additions and 57 deletions
@@ -5,6 +5,13 @@ import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/type
import { useReaderStore } from '@/store/readerStore';
import { getStyles } from '@/utils/style';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import {
handleKeydown,
handleMousedown,
handleClick,
handleMouseup,
} from '../utils/iframeEventHandlers';
import { eventDispatcher } from '@/utils/event';
export interface FoliateView extends HTMLElement {
open: (book: BookDoc) => Promise<void>;
@@ -20,6 +27,8 @@ export interface FoliateView extends HTMLElement {
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
renderer: {
setStyles?: (css: string) => void;
setAttribute: (name: string, value: string | number) => void;
@@ -47,8 +56,8 @@ const FoliateViewer: React.FC<{
bookDoc: BookDoc;
config: BookConfig;
}> = ({ bookKey, bookDoc, config }) => {
const viewRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState<FoliateView | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const [viewInited, setViewInited] = useState(false);
const isViewCreated = useRef(false);
const { setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
@@ -59,41 +68,6 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const handleKeydown = (event: KeyboardEvent) => {
// prevent default navigation keys in iframes
if (['Backspace', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
}
window.postMessage(
{
type: 'iframe-keydown',
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
},
'*',
);
};
const handleMousedown = (event: MouseEvent) => {
window.postMessage(
{
type: 'iframe-mousedown',
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
},
'*',
);
};
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail.doc) {
@@ -101,11 +75,35 @@ const FoliateViewer: React.FC<{
detail.doc.isEventListenersAdded = true;
detail.doc.addEventListener('keydown', handleKeydown);
detail.doc.addEventListener('mousedown', handleMousedown);
detail.doc.addEventListener('mouseup', handleMouseup);
detail.doc.addEventListener('click', handleClick);
}
}
};
useFoliateEvents(view, { onLoad: docLoadHandler, onRelocate: progressRelocateHandler });
const handleClickTurnPage = (msg: MessageEvent) => {
if (msg.data && msg.data.type === 'iframe-single-click') {
const viewElement = containerRef.current;
if (viewElement) {
const rect = viewElement.getBoundingClientRect();
const { screenX } = msg.data;
const eventConsumed = eventDispatcher.dispatchSync('iframe-single-click', { screenX });
if (!eventConsumed) {
if (screenX >= rect.left + rect.width / 2) {
viewRef.current?.goRight();
} else if (screenX < rect.left + rect.width / 2) {
viewRef.current?.goLeft();
}
}
}
}
};
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
onRelocate: progressRelocateHandler,
});
useEffect(() => {
if (isViewCreated.current) return;
@@ -114,8 +112,8 @@ const FoliateViewer: React.FC<{
await import('foliate-js/view.js');
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
document.body.append(view);
viewRef.current?.appendChild(view);
setView(view);
containerRef.current?.appendChild(view);
viewRef.current = view;
setFoliateView(bookKey, view);
await view.open(bookDoc);
@@ -148,6 +146,8 @@ const FoliateViewer: React.FC<{
await view.goToFraction(0);
}
setViewInited(true);
window.addEventListener('message', handleClickTurnPage);
};
openBook();
@@ -155,8 +155,8 @@ const FoliateViewer: React.FC<{
return () => {
console.log('Closing book', bookKey);
view?.close();
view?.remove();
viewRef.current?.close();
viewRef.current?.remove();
};
}, []);
@@ -164,7 +164,7 @@ const FoliateViewer: React.FC<{
const { booknotes = [] } = config;
const annotations = booknotes.filter((item) => item.type === 'annotation' && item.style);
try {
Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation)));
Promise.all(annotations.map((annotation) => viewRef.current?.addAnnotation(annotation)));
} catch (e) {
console.error(e);
}
@@ -176,7 +176,7 @@ const FoliateViewer: React.FC<{
}
}, [viewInited]);
return <div className='foliate-viewer h-[100%] w-[100%]' ref={viewRef} />;
return <div className='foliate-viewer h-[100%] w-[100%]' ref={containerRef} />;
};
export default FoliateViewer;
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
@@ -14,7 +14,6 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import Toast from '@/components/Toast';
import useOutsideClick from '@/hooks/useOutsideClick';
import AnnotationPopup from './AnnotationPopup';
import { uniqueId } from '@/utils/misc';
@@ -28,6 +27,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const progress = getProgress(bookKey)!;
const view = getView(bookKey);
const isShowingPopup = useRef(false);
const [selection, setSelection] = useState<TextSelection | null>();
const [showPopup, setShowPopup] = useState(false);
const [trianglePosition, setTrianglePosition] = useState<Position>();
@@ -84,14 +84,31 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelectedStyle(annotation.style!);
setSelectedColor(annotation.color!);
setSelection(selection);
console.log('show annotation', selection);
};
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]);
const popupRef = useOutsideClick<HTMLDivElement>(() => {
const handleDismissPopup = () => {
setShowPopup(false);
setSelection(null);
});
view?.deselect();
isShowingPopup.current = false;
};
useEffect(() => {
const handleSingleClick = (): boolean => {
if (showPopup || isShowingPopup.current) {
return true;
}
return false;
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
};
}, []);
useEffect(() => {
setHighlightOptionsVisible(!!(selection && selection.annotated));
@@ -101,9 +118,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const rect = gridFrame.getBoundingClientRect();
const triangPos = getPosition(selection.range, rect);
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setShowPopup(true);
setPopupPosition(popupPos);
setTrianglePosition(triangPos);
isShowingPopup.current = true;
}
}, [selection, bookKey]);
@@ -225,7 +244,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
];
return (
<div ref={notebookNewAnnotation ? null : popupRef}>
<div>
{showPopup && !notebookNewAnnotation && (
<div className='fixed inset-0' onClick={handleDismissPopup} />
)}
{showPopup && trianglePosition && popupPosition && (
<AnnotationPopup
buttons={buttons}
@@ -239,6 +261,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} />}
</div>
);
@@ -0,0 +1,107 @@
const doubleClickThreshold = 200;
const longHoldThreshold = 500;
let lastClickTime = 0;
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
export const handleKeydown = (event: KeyboardEvent) => {
if (['Backspace', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
}
window.postMessage(
{
type: 'iframe-keydown',
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
},
'*',
);
};
export const handleMousedown = (event: MouseEvent) => {
longHoldTimeout = setTimeout(() => {
longHoldTimeout = null;
}, longHoldThreshold);
window.postMessage(
{
type: 'iframe-mousedown',
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
};
export const handleMouseup = (event: MouseEvent) => {
window.postMessage(
{
type: 'iframe-mouseup',
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
};
export const handleClick = (event: MouseEvent) => {
const now = Date.now();
if (now - lastClickTime < doubleClickThreshold) {
lastClickTime = now;
window.postMessage(
{
type: 'iframe-double-click',
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
return;
}
lastClickTime = now;
setTimeout(() => {
if (Date.now() - lastClickTime >= doubleClickThreshold) {
let element: HTMLElement | null = event.target as HTMLElement;
while (element) {
if (element.tagName.toLowerCase() === 'a') {
return;
}
element = element.parentElement;
}
if (!longHoldTimeout) {
return;
}
window.postMessage(
{
type: 'iframe-single-click',
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
}
}, doubleClickThreshold);
};
+48 -8
View File
@@ -1,20 +1,60 @@
class EventDispatcher {
private target: EventTarget;
private syncListeners: Map<string, Set<(event: CustomEvent) => boolean>>;
private asyncListeners: Map<string, Set<(event: CustomEvent) => Promise<boolean>>>;
constructor() {
this.target = new EventTarget();
this.syncListeners = new Map();
this.asyncListeners = new Map();
}
on(event: string, callback: (event: CustomEvent) => void): void {
this.target.addEventListener(event, callback as EventListener);
on(event: string, callback: (event: CustomEvent) => Promise<boolean>): void {
if (!this.asyncListeners.has(event)) {
this.asyncListeners.set(event, new Set());
}
this.asyncListeners.get(event)!.add(callback);
}
off(event: string, callback: (event: CustomEvent) => void): void {
this.target.removeEventListener(event, callback as EventListener);
off(event: string, callback: (event: CustomEvent) => Promise<boolean>): void {
this.asyncListeners.get(event)?.delete(callback);
}
dispatch(event: string, detail?: unknown): void {
this.target.dispatchEvent(new CustomEvent(event, { detail }));
async dispatch(event: string, detail?: unknown): Promise<boolean> {
const listeners = this.asyncListeners.get(event);
if (listeners) {
const customEvent = new CustomEvent(event, { detail });
for (const listener of listeners) {
const consumed = await listener(customEvent);
if (consumed) {
return true;
}
}
}
return false;
}
onSync(event: string, callback: (event: CustomEvent) => boolean): void {
if (!this.syncListeners.has(event)) {
this.syncListeners.set(event, new Set());
}
this.syncListeners.get(event)!.add(callback);
}
offSync(event: string, callback: (event: CustomEvent) => boolean): void {
this.syncListeners.get(event)?.delete(callback);
}
dispatchSync(event: string, detail?: unknown): boolean {
const listeners = this.syncListeners.get(event);
if (listeners) {
const customEvent = new CustomEvent(event, { detail });
for (const listener of listeners) {
const consumed = listener(customEvent);
if (consumed) {
return true;
}
}
}
return false;
}
}