Add single click/tap to turn page
This commit is contained in:
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user