diff --git a/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx index 0a31c35f..b2cf6b46 100644 --- a/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { MdArrowBack } from 'react-icons/md'; import { Position } from '@/utils/sel'; import { useTranslation } from '@/hooks/useTranslation'; import Popup from '@/components/Popup'; @@ -34,8 +35,118 @@ const WiktionaryPopup: React.FC = ({ onDismiss, }) => { const _ = useTranslation(); - const [lookupWord, setLookupWord] = useState(word); - const isLookingUp = useRef(false); + const [history, setHistory] = useState<{ items: string[]; index: number }>({ + items: [word], + index: 0, + }); + const lastLookupRef = useRef(''); + const mainRef = useRef(null); + const footerRef = useRef(null); + const lastScrollTopRef = useRef(0); + const lastDirectionRef = useRef<'up' | 'down' | null>(null); + const scrollDeltaRef = useRef(0); + const rafRef = useRef(null); + const [isBackVisible, setIsBackVisible] = useState(false); + const lookupWord = history.items[history.index] ?? word; + const canGoBack = history.index > 0; + const showBackButton = canGoBack && isBackVisible; + + useEffect(() => { + setHistory({ items: [word], index: 0 }); + }, [word]); + + useEffect(() => { + if (!canGoBack) { + setIsBackVisible(false); + lastScrollTopRef.current = 0; + lastDirectionRef.current = null; + scrollDeltaRef.current = 0; + return; + } + setIsBackVisible(true); + }, [canGoBack]); + + useEffect(() => { + if (!canGoBack) return; + const main = mainRef.current; + if (!main) return; + + const handleScroll = () => { + if (rafRef.current !== null) return; + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + const currentScrollTop = main.scrollTop; + const delta = currentScrollTop - lastScrollTopRef.current; + if (delta === 0) return; + + if (currentScrollTop <= 4) { + setIsBackVisible(true); + lastDirectionRef.current = null; + scrollDeltaRef.current = 0; + lastScrollTopRef.current = currentScrollTop; + return; + } + + const direction: 'up' | 'down' = delta > 0 ? 'down' : 'up'; + if (direction !== lastDirectionRef.current) { + lastDirectionRef.current = direction; + scrollDeltaRef.current = 0; + } + + scrollDeltaRef.current += Math.abs(delta); + const hideThreshold = 14; + const showThreshold = 8; + + if (direction === 'down' && scrollDeltaRef.current >= hideThreshold) { + setIsBackVisible(false); + scrollDeltaRef.current = 0; + } else if (direction === 'up' && scrollDeltaRef.current >= showThreshold) { + setIsBackVisible(true); + scrollDeltaRef.current = 0; + } + + lastScrollTopRef.current = currentScrollTop; + }); + }; + + lastScrollTopRef.current = main.scrollTop; + main.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + main.removeEventListener('scroll', handleScroll); + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [canGoBack]); + + useEffect(() => { + setIsBackVisible(true); + lastScrollTopRef.current = 0; + lastDirectionRef.current = null; + scrollDeltaRef.current = 0; + if (mainRef.current) { + mainRef.current.scrollTop = 0; + } + }, [lookupWord]); + + const pushHistory = (nextWord: string) => { + const trimmedWord = nextWord.trim(); + if (!trimmedWord) return; + setHistory((prev) => { + const currentWord = prev.items[prev.index]; + if (currentWord === trimmedWord) return prev; + const items = [...prev.items.slice(0, prev.index + 1), trimmedWord]; + return { items, index: items.length - 1 }; + }); + }; + + const handleBack = () => { + setHistory((prev) => { + if (prev.index === 0) return prev; + return { ...prev, index: prev.index - 1 }; + }); + }; const interceptDictLinks = (definition: string): HTMLElement[] => { const container = document.createElement('div'); @@ -48,8 +159,7 @@ const WiktionaryPopup: React.FC = ({ if (title) { link.addEventListener('click', (event) => { event.preventDefault(); - setLookupWord(title); - isLookingUp.current = false; + pushHistory(title); }); link.className = 'text-primary underline cursor-pointer'; @@ -60,12 +170,13 @@ const WiktionaryPopup: React.FC = ({ }; useEffect(() => { - if (isLookingUp.current) { - return; - } - isLookingUp.current = true; - const main = document.querySelector('main') as HTMLElement; - const footer = document.querySelector('footer') as HTMLElement; + const langCode = typeof lang === 'string' ? lang : lang?.[0]; + const lookupKey = `${lookupWord}::${langCode || ''}`; + if (lastLookupRef.current === lookupKey) return; + lastLookupRef.current = lookupKey; + const main = mainRef.current; + const footer = footerRef.current; + if (!main || !footer) return; const fetchDefinitions = async (word: string, language?: string) => { main.innerHTML = ''; @@ -158,7 +269,6 @@ const WiktionaryPopup: React.FC = ({ } }; - const langCode = typeof lang === 'string' ? lang : lang?.[0]; fetchDefinitions(lookupWord, langCode); }, [_, lookupWord, lang]); @@ -172,9 +282,33 @@ const WiktionaryPopup: React.FC = ({ className='select-text' onDismiss={onDismiss} > -
-
-