Add wikipedia and translation of selected text

This commit is contained in:
chrox
2024-11-21 01:53:50 +01:00
parent e298e0bbe6
commit 53feabe256
9 changed files with 440 additions and 67 deletions
@@ -31,9 +31,12 @@
}
]
},
{
"identifier": "http:default",
"allow": [{ "url": "https://*.deepl.com" }]
},
"dialog:default",
"os:default",
"http:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-minimize",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Readest",
"version": "0.6.5",
"version": "0.6.8",
"identifier": "com.bilingify.digest",
"build": {
"frontendDist": "../out",
@@ -13,7 +13,8 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://fonts.gstatic.com",
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org",
"img-src": "'self' blob: data: asset: http://asset.localhost",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
"frame-src": "'self' blob: asset: http://asset.localhost",
+6 -5
View File
@@ -17,11 +17,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}, [pathname]);
React.useEffect(() => {
if (process.env['NODE_ENV'] === 'production') {
document.oncontextmenu = (event) => {
event.preventDefault();
};
}
// TODO: disabled for now
// if (process.env['NODE_ENV'] === 'production') {
// document.oncontextmenu = (event) => {
// event.preventDefault();
// };
// }
}, []);
return (
@@ -1,4 +1,5 @@
import React from 'react';
import Popup from '@/components/Popup';
import PopupButton from './PopupButton';
import HighlightOptions from './HighlightOptions';
import { Position } from '@/utils/sel';
@@ -30,29 +31,15 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
popupHeight,
onHighlight,
}) => {
const isPopupAbove = trianglePosition.dir === 'up';
return (
<div>
<div
className='triangle absolute'
style={{
left: `${trianglePosition.point.x}px`,
top: `${trianglePosition.point.y}px`,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderBottom: isPopupAbove ? 'none' : '6px solid #465563',
borderTop: isPopupAbove ? '6px solid #465563' : 'none',
transform: 'translateX(-50%)',
}}
/>
<div
className='selection-popup absolute rounded-lg bg-gray-600 px-4 text-white shadow-lg'
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
left: `${position.point.x}px`,
top: `${position.point.y}px`,
}}
<Popup
width={popupWidth}
height={popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 px-4 text-white'
triangleClassName='text-gray-600'
>
<div className='selection-buttons flex h-11 items-center justify-between'>
{buttons.map((button, index) => (
@@ -64,7 +51,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
/>
))}
</div>
</div>
</Popup>
{highlightOptionsVisible && (
<HighlightOptions
style={{
@@ -73,7 +60,8 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) * (isPopupAbove ? -1 : 1)
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}}
selectedStyle={selectedStyle}
@@ -5,6 +5,8 @@ import { PiHighlighterFill } from 'react-icons/pi';
import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { SiDeepl } from 'react-icons/si';
import { Overlayer } from 'foliate-js/overlayer.js';
import { useEnv } from '@/context/EnvContext';
@@ -17,6 +19,8 @@ import { eventDispatcher } from '@/utils/event';
import Toast from '@/components/Toast';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DeepLPopup from './DeepLPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { envConfig } = useEnv();
@@ -34,7 +38,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [selection, setSelection] = useState<TextSelection | null>();
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [wiktionaryWord, setWiktionaryWord] = useState('');
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
const [showDeepLPopup, setShowDeepLPopup] = useState(false);
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
@@ -50,7 +55,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const dictPopupWidth = 400;
const dictPopupHeight = 300;
const annotPopupWidth = 240;
const annotPopupWidth = 280;
const annotPopupHeight = 44;
const popupPadding = 10;
@@ -102,6 +107,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection(null);
setShowAnnotPopup(false);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
isShowingPopup.current = false;
};
@@ -266,7 +273,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowWiktionaryPopup(true);
setWiktionaryWord(selection.text);
};
const handleWikipedia = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowWikipediaPopup(true);
};
const handleTranslation = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowDeepLPopup(true);
};
const selectionAnnotated = selection?.annotated;
@@ -279,21 +297,24 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
},
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
{ tooltipText: 'Dictionary', Icon: FaWikipediaW, onClick: handleDictionary },
{ tooltipText: 'Dictionary', Icon: BsTranslate, onClick: handleDictionary },
{ tooltipText: 'Translate', Icon: SiDeepl, onClick: handleTranslation },
{ tooltipText: 'Wikipedia', Icon: FaWikipediaW, onClick: handleWikipedia },
];
return (
<div>
{(showAnnotPopup || showWiktionaryPopup) && !notebookNewAnnotation && (
<div
className='fixed inset-0'
onClick={handleDismissPopupAndSelection}
onContextMenu={handleDismissPopup}
/>
)}
{(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) &&
!notebookNewAnnotation && (
<div
className='fixed inset-0'
onClick={handleDismissPopupAndSelection}
onContextMenu={handleDismissPopup}
/>
)}
{showWiktionaryPopup && trianglePosition && dictPopupPosition && (
<WiktionaryPopup
word={wiktionaryWord}
word={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
@@ -301,6 +322,25 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupHeight={dictPopupHeight}
/>
)}
{showWikipediaPopup && trianglePosition && dictPopupPosition && (
<WikipediaPopup
text={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
/>
)}
{showDeepLPopup && trianglePosition && dictPopupPosition && (
<DeepLPopup
text={selection?.text as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
/>
)}
{showAnnotPopup && trianglePosition && annotPopupPosition && (
<AnnotationPopup
buttons={buttons}
@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { fetch } from '@tauri-apps/plugin-http';
const LANGUAGES = {
AUTO: 'Auto Detect',
EN: 'English',
DE: 'German',
FR: 'French',
ES: 'Spanish',
ID: 'Indonesian',
IT: 'Italian',
PT: 'Portuguese',
NL: 'Dutch',
PL: 'Polish',
RU: 'Russian',
KO: 'Korean',
JA: 'Japanese',
'ZH-HANS': 'Chinese (Simplified)',
'ZH-HANT': 'Chinese (Traditional)',
};
interface DeepLPopupProps {
text: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
}
const DeepLPopup: React.FC<DeepLPopupProps> = ({
text,
position,
trianglePosition,
popupWidth,
popupHeight,
}) => {
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState('EN');
const [translation, setTranslation] = useState<string | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<keyof typeof LANGUAGES | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceLang(event.target.value);
};
const handleTargetLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setTargetLang(event.target.value);
};
useEffect(() => {
const fetchTranslation = async () => {
setLoading(true);
setError(null);
setTranslation(null);
try {
const response = await fetch('https://api-free.deepl.com/v2/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `DeepL-Auth-Key ${process.env['NEXT_PUBLIC_DEEPL_API_KEY']}`,
},
body: JSON.stringify({
text: [text],
target_lang: targetLang.toUpperCase(),
source_lang: sourceLang === 'AUTO' ? undefined : sourceLang.toUpperCase(),
}),
});
if (!response.ok) {
throw new Error('Failed to fetch translation');
}
const data = await response.json();
const translatedText = data.translations[0]?.text;
const detectedSource = data.translations[0]?.detected_source_language;
if (!translatedText) {
throw new Error('No translation found');
}
if (sourceLang === 'AUTO' && detectedSource) {
setDetectedSourceLang(detectedSource);
}
setTranslation(translatedText);
} catch (err) {
console.error(err);
setError('Unable to fetch the translation. Try again later.');
} finally {
setLoading(false);
}
};
fetchTranslation();
}, [text, sourceLang, targetLang]);
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
height={popupHeight}
position={position}
className='bg-neutral select-text'
triangleClassName='text-neutral'
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>Original Text</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
className='select h-8 min-h-8 rounded-md border-none bg-gray-400 text-sm focus:outline-none focus:ring-0'
>
{Object.entries(LANGUAGES).map(([code, name]) => {
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} (detected)`
: name}
</option>
);
})}
</select>
</div>
<p className='text-base'>{text}</p>
</div>
<div className='text-neutral-content relative h-[50%] 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>
<select
value={targetLang}
onChange={handleTargetLangChange}
className='select h-8 min-h-8 rounded-md border-none bg-gray-400 text-sm focus:outline-none focus:ring-0'
>
{Object.entries(LANGUAGES)
.filter(([code]) => code !== 'AUTO')
.map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
</div>
{loading ? (
<p className='text-base italic text-gray-500'>Loading...</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<div>
<p className='text-base'>{translation || 'No translation available.'}</p>
<div className='pt-4 text-sm opacity-60'>
Translated by{' '}
<a href='https://www.deepl.com/' target='_blank' rel='noopener noreferrer'>
DeepL
</a>
.
</div>
</div>
)}
</div>
</Popup>
</div>
);
};
export default DeepLPopup;
@@ -0,0 +1,128 @@
import React, { useEffect, useRef } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
interface WikipediaPopupProps {
text: string;
lang: string;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
}
const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
text,
lang,
position,
trianglePosition,
popupWidth,
popupHeight,
}) => {
const isLoading = useRef(false);
useEffect(() => {
if (isLoading.current) {
return;
}
isLoading.current = true;
const main = document.querySelector('main') as HTMLElement;
const footer = document.querySelector('footer') as HTMLElement;
const fetchSummary = async (query: string, language: string) => {
main.innerHTML = '';
footer.dataset['state'] = 'loading';
try {
const response = await fetch(
`https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error('Failed to fetch Wikipedia summary');
}
const data = await response.json();
const hgroup = document.createElement('hgroup');
hgroup.style.color = 'white';
hgroup.style.backgroundPosition = 'center center';
hgroup.style.backgroundSize = 'cover';
hgroup.style.backgroundColor = 'rgba(0, 0, 0, .4)';
hgroup.style.backgroundBlendMode = 'darken';
hgroup.style.borderRadius = '6px';
hgroup.style.padding = '12px';
hgroup.style.marginBottom = '12px';
hgroup.style.minHeight = '100px';
const h1 = document.createElement('h1');
h1.innerHTML = data.titles.display;
h1.className = 'text-lg font-bold';
hgroup.append(h1);
if (data.description) {
const description = document.createElement('p');
description.innerText = data.description;
hgroup.appendChild(description);
}
if (data.thumbnail) {
hgroup.style.backgroundImage = `url("${data.thumbnail.source}")`;
}
const contentDiv = document.createElement('div');
contentDiv.innerHTML = data.extract_html;
contentDiv.className = 'p-2 text-sm';
contentDiv.dir = data.dir;
main.append(hgroup, contentDiv);
footer.dataset['state'] = 'loaded';
} catch (error) {
console.error(error);
const errorDiv = document.createElement('div');
const h1 = document.createElement('h1');
h1.innerText = 'Error';
const errorMsg = document.createElement('p');
errorMsg.innerHTML = `Unable to load the article. Try searching directly on <a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
query,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>.`;
errorDiv.append(h1, errorMsg);
main.appendChild(errorDiv);
footer.dataset['state'] = 'error';
}
};
const langCode = typeof lang === 'string' ? lang : lang?.[0];
fetchSummary(text, langCode);
}, [text, lang]);
return (
<div>
<Popup
width={popupWidth}
height={popupHeight}
position={position}
trianglePosition={trianglePosition}
className='bg-neutral select-text overflow-y-auto'
triangleClassName='text-neutral'
>
<main className='p-2 font-sans'></main>
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
<div className='p-4 text-sm opacity-60'>
From <a id='link'>Wikipedia</a>, released under the{' '}
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
CC BY-SA License
</a>
.
</div>
</footer>
</Popup>
</div>
);
};
export default WikipediaPopup;
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Position } from '@/utils/sel';
import Popup from '@/components/Popup';
type Definition = {
definition: string;
@@ -12,7 +13,7 @@ type Result = {
language: string;
};
interface WiktionaryProps {
interface WiktionaryPopupProps {
word: string;
lang?: string;
position: Position;
@@ -21,7 +22,7 @@ interface WiktionaryProps {
popupHeight: number;
}
const WiktionaryPopup: React.FC<WiktionaryProps> = ({
const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
word,
lang,
position,
@@ -39,7 +40,7 @@ const WiktionaryPopup: React.FC<WiktionaryProps> = ({
const links = container.querySelectorAll<HTMLAnchorElement>('a[rel="mw:WikiLink"]');
links.forEach((link) => {
const title = link.getAttribute('title'); // Get the title attribute
const title = link.getAttribute('title');
if (title) {
link.addEventListener('click', (event) => {
event.preventDefault();
@@ -157,26 +158,13 @@ const WiktionaryPopup: React.FC<WiktionaryProps> = ({
return (
<div>
<div
className='triangle text-neutral absolute z-10'
style={{
left: `${trianglePosition.point.x}px`,
top: `${trianglePosition.point.y}px`,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderBottom: trianglePosition.dir === 'up' ? 'none' : `6px solid`,
borderTop: trianglePosition.dir === 'up' ? `6px solid` : 'none',
transform: 'translateX(-50%)',
}}
/>
<div
className='bg-neutral absolute overflow-y-auto rounded-xl shadow-lg'
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
left: `${position.point.x}px`,
top: `${position.point.y}px`,
}}
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
height={popupHeight}
position={position}
className='bg-neutral select-text overflow-y-auto'
triangleClassName='text-neutral'
>
<main className='p-4 font-sans' />
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
@@ -185,7 +173,7 @@ const WiktionaryPopup: React.FC<WiktionaryProps> = ({
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
</div>
</footer>
</div>
</Popup>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
import { Position } from '@/utils/sel';
const Popup = ({
width,
height,
position,
trianglePosition,
children,
className = '',
triangleClassName = '',
additionalStyle = {},
}: {
width: number;
height: number;
position: Position;
trianglePosition: Position;
children: React.ReactNode;
className?: string;
triangleClassName?: string;
additionalStyle?: React.CSSProperties;
}) => (
<div>
<div
className={`triangle absolute z-10 ${triangleClassName}`}
style={{
left: `${trianglePosition.point.x}px`,
top: `${trianglePosition.point.y}px`,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderBottom: trianglePosition.dir === 'up' ? 'none' : `6px solid`,
borderTop: trianglePosition.dir === 'up' ? `6px solid` : 'none',
transform: 'translateX(-50%)',
}}
/>
<div
className={`absolute rounded-lg font-sans shadow-lg ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
left: `${position.point.x}px`,
top: `${position.point.y}px`,
...additionalStyle,
}}
>
{children}
</div>
</div>
);
export default Popup;