diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 9b33571a..ac16705a 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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; @@ -20,6 +27,8 @@ export interface FoliateView extends HTMLElement { addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string }; search: (config: BookSearchConfig) => AsyncGenerator; 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(null); - const [view, setView] = useState(null); + const containerRef = useRef(null); + const viewRef = useRef(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
; + return
; }; export default FoliateViewer; diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 01a5c143..b9af2766 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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(); const [showPopup, setShowPopup] = useState(false); const [trianglePosition, setTrianglePosition] = useState(); @@ -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(() => { + 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 ( -
+
+ {showPopup && !notebookNewAnnotation && ( +
+ )} {showPopup && trianglePosition && popupPosition && ( = ({ bookKey }) => { onHighlight={handleHighlight} /> )} + {toastMessage && }
); diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts new file mode 100644 index 00000000..6a8bf5f7 --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -0,0 +1,107 @@ +const doubleClickThreshold = 200; +const longHoldThreshold = 500; +let lastClickTime = 0; +let longHoldTimeout: ReturnType | 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); +}; diff --git a/apps/readest-app/src/utils/event.ts b/apps/readest-app/src/utils/event.ts index 68ad59f4..c482a352 100644 --- a/apps/readest-app/src/utils/event.ts +++ b/apps/readest-app/src/utils/event.ts @@ -1,20 +1,60 @@ class EventDispatcher { - private target: EventTarget; + private syncListeners: Map boolean>>; + private asyncListeners: Map Promise>>; 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): 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): 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 { + 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; } }