forked from akai/readest
feat: back button dictionary (#3220)
* feat: add back button with history in dictionry/wikitionary * fix: flicker back button
This commit is contained in:
@@ -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<WiktionaryPopupProps> = ({
|
||||
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<HTMLElement | null>(null);
|
||||
const footerRef = useRef<HTMLElement | null>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const lastDirectionRef = useRef<'up' | 'down' | null>(null);
|
||||
const scrollDeltaRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(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<WiktionaryPopupProps> = ({
|
||||
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<WiktionaryPopupProps> = ({
|
||||
};
|
||||
|
||||
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<WiktionaryPopupProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
fetchDefinitions(lookupWord, langCode);
|
||||
}, [_, lookupWord, lang]);
|
||||
|
||||
@@ -172,9 +282,33 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
className='select-text'
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
<main className='flex-grow overflow-y-auto p-4 font-sans' />
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='relative flex h-full flex-col'>
|
||||
{canGoBack && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleBack}
|
||||
aria-label={_('Back')}
|
||||
className={`btn btn-ghost btn-circle text-base-content bg-base-200/80 hover:bg-base-200 absolute left-2 top-2 h-8 min-h-8 w-8 p-0 shadow-sm transition-[opacity,transform] duration-200 ease-out ${
|
||||
showBackButton
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-1 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<MdArrowBack size={18} />
|
||||
</button>
|
||||
)}
|
||||
<main
|
||||
ref={mainRef}
|
||||
className='flex-grow overflow-y-auto px-4 pb-4 font-sans'
|
||||
style={{
|
||||
paddingTop: showBackButton ? 48 : 16,
|
||||
transition: 'padding-top 180ms ease-out',
|
||||
}}
|
||||
/>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'
|
||||
>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
Source: Wiktionary (CC BY-SA)
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user