feat: back button dictionary (#3220)

* feat: add back button with history in dictionry/wikitionary

* fix: flicker back button
This commit is contained in:
Mohammed Efaz
2026-02-08 12:27:05 +01:00
committed by GitHub
parent d8817a88b9
commit fb49ddf484
@@ -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>