layout: responsive translator popup, also closes #1157 (#1160)

This commit is contained in:
Huang Xin
2025-05-16 00:19:32 +08:00
committed by GitHub
parent 048867e595
commit 93876a73cc
4 changed files with 198 additions and 122 deletions
@@ -73,7 +73,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const dictPopupWidth = Math.min(480, maxWidth);
const dictPopupHeight = Math.min(300, maxHeight);
const transPopupWidth = Math.min(480, maxWidth);
const transPopupHeight = Math.min(360, maxHeight);
const transPopupHeight = Math.min(265, maxHeight);
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 0;
@@ -91,13 +91,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
view?.deselect();
};
const {
handleScroll,
handlePointerup,
handleSelectionchange,
handleAnnotPopup,
handleShowAnnotation,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const { handleScroll, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup } =
useTextSelector(bookKey, setSelection, handleDismissPopup);
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -163,15 +158,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelectedStyle(annotation.style!);
setSelectedColor(annotation.color!);
setSelection(selection);
handleShowAnnotation();
handleUpToPopup();
};
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
useEffect(() => {
handleAnnotPopup(showAnnotPopup);
handleShowPopup(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAnnotPopup]);
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]);
useEffect(() => {
eventDispatcher.on('export-annotations', handleExportMarkdown);
@@ -214,11 +209,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setShowAnnotPopup(true);
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setTrianglePosition(triangPos);
handleShowAnnotPopup();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
@@ -245,6 +240,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
const handleShowAnnotPopup = () => {
setShowAnnotPopup(true);
setShowDeepLPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
};
const handleCopy = () => {
if (!selection || !selection.text) return;
eventDispatcher.dispatch('toast', {
@@ -1,33 +1,33 @@
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { getAPIBaseUrl } from '@/services/environment';
import { useAuth } from '@/context/AuthContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useAuth } from '@/context/AuthContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getAPIBaseUrl } from '@/services/environment';
import { TRANSLATED_LANGS } from '@/services/constants';
const LANGUAGES = {
AUTO: 'Auto Detect',
EN: 'English',
DE: 'German',
FR: 'French',
ES: 'Spanish',
IT: 'Italian',
EL: 'Greek',
PT: 'Portuguese',
NL: 'Dutch',
PL: 'Polish',
UK: 'Ukrainian',
RU: 'Russian',
AR: 'Arabic',
TR: 'Turkish',
ID: 'Indonesian',
KO: 'Korean',
JA: 'Japanese',
'ZH-HANS': 'Chinese (Simplified)',
'ZH-HANT': 'Chinese (Traditional)',
const notSupportedLangs = ['hi', 'vi'];
const generateTranslatorLangs = () => {
const langs = { ...TRANSLATED_LANGS };
const result: Record<string, string> = {};
for (const [code, name] of Object.entries(langs)) {
if (notSupportedLangs.includes(code)) continue;
let newCode = code.toUpperCase();
if (newCode === 'ZH-CN') {
newCode = 'ZH-HANS';
} else if (newCode === 'ZH-TW') {
newCode = 'ZH-HANT';
}
result[newCode] = name;
}
return result;
};
const TRANSLATOR_LANGS = generateTranslatorLangs();
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
interface DeepLPopupProps {
@@ -51,7 +51,9 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
const [translation, setTranslation] = useState<string | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<keyof typeof LANGUAGES | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<
keyof typeof TRANSLATOR_LANGS | null
>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -117,47 +119,59 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang]);
const popupPadding = useResponsiveSize(10);
const maxHeight = window.innerHeight - 2 * popupPadding;
const popupMaxHeight = Math.min(360, maxHeight);
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
height={popupHeight}
minHeight={popupHeight}
maxHeight={popupMaxHeight}
position={position}
className='select-text'
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
triangleClassName='text-gray-600'
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
className='select text-neutral-content h-8 min-h-8 rounded-md border-none bg-neutral-200/50 text-sm focus:outline-none focus:ring-0'
className='select h-8 min-h-8 rounded-md border-none bg-gray-600 text-sm text-white/75 focus:outline-none focus:ring-0'
>
{Object.entries(LANGUAGES).map(([code, name]) => {
{[
['AUTO', _('Auto Detect')],
...Object.entries(TRANSLATOR_LANGS).sort((a, b) => a[1].localeCompare(b[1])),
].map(([code, name]) => {
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
? `${TRANSLATOR_LANGS[detectedSourceLang] || detectedSourceLang} ` +
_('(detected)')
: name}
</option>
);
})}
</select>
</div>
<p className='text-base'>{text}</p>
<p className='text-base text-white/90'>{text}</p>
</div>
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
<div className='overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
className='select text-neutral-content h-8 min-h-8 rounded-md border-none bg-neutral-200/50 text-sm focus:outline-none focus:ring-0'
className='select h-8 min-h-8 rounded-md border-none bg-gray-600 text-sm text-white/75 focus:outline-none focus:ring-0'
>
{Object.entries(LANGUAGES)
.filter(([code]) => code !== 'AUTO')
{Object.entries(TRANSLATOR_LANGS)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => (
<option key={code} value={code}>
{name}
@@ -172,7 +186,9 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<p className='text-base text-red-600'>{error}</p>
) : (
<div>
<p className='text-base'>{translation || 'No translation available.'}</p>
<p className='text-base text-white/90'>
{translation || 'No translation available.'}
</p>
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
</div>
)}
@@ -19,9 +19,9 @@ export const useTextSelector = (
const primaryLang = bookData.book?.primaryLanguage || 'en';
const osPlatform = getOSPlatform();
const isPopuped = useRef(false);
const isUpToPopup = useRef(false);
const isTextSelected = useRef(false);
const isAnnotPopuped = useRef(false);
const isAnnotUpToPopup = useRef(false);
const selectionPosition = useRef<number | null>(null);
const isValidSelection = (sel: Selection) => {
@@ -67,10 +67,13 @@ export const useTextSelector = (
const sel = doc.getSelection() as Selection;
if (osPlatform === 'ios') return;
if (!isValidSelection(sel)) {
if (!isAnnotUpToPopup.current) {
if (!isUpToPopup.current) {
handleDismissPopup();
isTextSelected.current = false;
}
if (isPopuped.current) {
isUpToPopup.current = false;
}
return;
}
@@ -79,6 +82,7 @@ export const useTextSelector = (
if (osPlatform === 'android') {
makeSelection(sel, index, false);
}
isUpToPopup.current = true;
};
const handlePointerup = (doc: Document, index: number) => {
// Available on iOS and Desktop, fired at touchend or mouseup
@@ -101,17 +105,20 @@ export const useTextSelector = (
}
};
const handleAnnotPopup = (showAnnotPopup: boolean) => {
const handleShowPopup = (showPopup: boolean) => {
setTimeout(
() => {
isAnnotPopuped.current = showAnnotPopup;
if (showPopup && !isPopuped.current) {
isUpToPopup.current = false;
}
isPopuped.current = showPopup;
},
['android', 'ios'].includes(osPlatform) ? 0 : 500,
);
};
const handleShowAnnotation = () => {
isAnnotUpToPopup.current = true;
const handleUpToPopup = () => {
isUpToPopup.current = true;
};
useEffect(() => {
@@ -125,16 +132,17 @@ export const useTextSelector = (
useEffect(() => {
const handleSingleClick = (): boolean => {
if (isAnnotUpToPopup.current) {
isAnnotUpToPopup.current = false;
if (isUpToPopup.current) {
isUpToPopup.current = false;
return true;
}
if (isTextSelected.current) {
handleDismissPopup();
isTextSelected.current = false;
view?.deselect();
return true;
}
if (isAnnotPopuped.current) {
if (isPopuped.current) {
handleDismissPopup();
return true;
}
@@ -152,7 +160,7 @@ export const useTextSelector = (
handleScroll,
handlePointerup,
handleSelectionchange,
handleAnnotPopup,
handleShowAnnotation,
handleShowPopup,
handleUpToPopup,
};
};
+111 -61
View File
@@ -1,8 +1,11 @@
import { Position } from '@/utils/sel';
import { useEffect, useRef, useState } from 'react';
const Popup = ({
width,
height,
minHeight,
maxHeight,
position,
trianglePosition,
children,
@@ -11,74 +14,121 @@ const Popup = ({
additionalStyle = {},
}: {
width: number;
height: number;
height?: number;
minHeight?: number;
maxHeight?: number;
position?: Position;
trianglePosition?: Position;
children: React.ReactNode;
className?: string;
triangleClassName?: string;
additionalStyle?: React.CSSProperties;
}) => (
<div>
<div
id='popup-container'
className={`bg-base-300 absolute rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
left: `${position ? position.point.x : -999}px`,
top: `${position ? position.point.y : -999}px`,
...additionalStyle,
}}
>
{children}
</div>
<div
className={`triangle text-base-300 absolute ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
? `${trianglePosition.point.x}px`
: trianglePosition?.dir === 'right'
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [childrenHeight, setChildrenHeight] = useState(height || minHeight || 0);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
if (newHeight !== childrenHeight) {
setChildrenHeight(newHeight);
return;
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!containerRef.current || !position || !trianglePosition) return;
if (position.dir !== 'up') {
setAdjustedPosition(position);
return;
}
const containerHeight = childrenHeight || containerRef.current.offsetHeight;
const newPosition = {
...position,
point: {
...position.point,
y: trianglePosition.point.y - containerHeight,
},
};
setAdjustedPosition(newPosition);
}, [position, trianglePosition, childrenHeight]);
return (
<div>
<div
id='popup-container'
ref={containerRef}
className={`bg-base-300 absolute rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: height ? `${height}px` : 'auto',
minHeight: minHeight ? `${minHeight}px` : 'none',
maxHeight: maxHeight ? `${maxHeight}px` : 'none',
left: `${adjustedPosition ? adjustedPosition.point.x : -999}px`,
top: `${adjustedPosition ? adjustedPosition.point.y : -999}px`,
...additionalStyle,
}}
>
{children}
</div>
<div
className={`triangle text-base-300 absolute ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
? `${trianglePosition.point.x}px`
: `${trianglePosition ? trianglePosition.point.x : -999}px`,
top:
trianglePosition?.dir === 'up'
? `${trianglePosition.point.y}px`
: trianglePosition?.dir === 'down'
: trianglePosition?.dir === 'right'
? `${trianglePosition.point.x}px`
: `${trianglePosition ? trianglePosition.point.x : -999}px`,
top:
trianglePosition?.dir === 'up'
? `${trianglePosition.point.y}px`
: `${trianglePosition ? trianglePosition.point.y : -999}px`,
borderLeft:
trianglePosition?.dir === 'right'
? 'none'
: trianglePosition?.dir === 'left'
? `6px solid`
: '6px solid transparent',
borderRight:
trianglePosition?.dir === 'left'
? 'none'
: trianglePosition?.dir === 'right'
? `6px solid`
: '6px solid transparent',
borderTop:
trianglePosition?.dir === 'down'
? 'none'
: trianglePosition?.dir === 'up'
? `6px solid`
: '6px solid transparent',
borderBottom:
trianglePosition?.dir === 'up'
? 'none'
: trianglePosition?.dir === 'down'
? `6px solid`
: '6px solid transparent',
transform:
trianglePosition?.dir === 'left' || trianglePosition?.dir === 'right'
? 'translateY(-50%)'
: 'translateX(-50%)',
}}
/>
</div>
);
: trianglePosition?.dir === 'down'
? `${trianglePosition.point.y}px`
: `${trianglePosition ? trianglePosition.point.y : -999}px`,
borderLeft:
trianglePosition?.dir === 'right'
? 'none'
: trianglePosition?.dir === 'left'
? `6px solid`
: '6px solid transparent',
borderRight:
trianglePosition?.dir === 'left'
? 'none'
: trianglePosition?.dir === 'right'
? `6px solid`
: '6px solid transparent',
borderTop:
trianglePosition?.dir === 'down'
? 'none'
: trianglePosition?.dir === 'up'
? `6px solid`
: '6px solid transparent',
borderBottom:
trianglePosition?.dir === 'up'
? 'none'
: trianglePosition?.dir === 'down'
? `6px solid`
: '6px solid transparent',
transform:
trianglePosition?.dir === 'left' || trianglePosition?.dir === 'right'
? 'translateY(-50%)'
: 'translateX(-50%)',
}}
/>
</div>
);
};
export default Popup;