From 53feabe25627005318e54a7710ae42824f6ecda4 Mon Sep 17 00:00:00 2001 From: chrox Date: Thu, 21 Nov 2024 01:53:50 +0100 Subject: [PATCH] Add wikipedia and translation of selected text --- .../src-tauri/capabilities/default.json | 5 +- apps/readest-app/src-tauri/tauri.conf.json | 5 +- apps/readest-app/src/app/layout.tsx | 11 +- .../components/annotator/AnnotationPopup.tsx | 34 ++-- .../reader/components/annotator/Annotator.tsx | 64 +++++-- .../components/annotator/DeepLPopup.tsx | 174 ++++++++++++++++++ .../components/annotator/WikipediaPopup.tsx | 128 +++++++++++++ .../components/annotator/WiktionaryPopup.tsx | 36 ++-- apps/readest-app/src/components/Popup.tsx | 50 +++++ 9 files changed, 440 insertions(+), 67 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/annotator/DeepLPopup.tsx create mode 100644 apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx create mode 100644 apps/readest-app/src/components/Popup.tsx diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index e38f4e29..05faf4d4 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -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", diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index ab8e4415..a645cbde 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -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", diff --git a/apps/readest-app/src/app/layout.tsx b/apps/readest-app/src/app/layout.tsx index 4a3deabd..48da669e 100644 --- a/apps/readest-app/src/app/layout.tsx +++ b/apps/readest-app/src/app/layout.tsx @@ -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 ( diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx index 089ebc85..b5bfaf39 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx @@ -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 = ({ popupHeight, onHighlight, }) => { - const isPopupAbove = trianglePosition.dir === 'up'; return (
-
-
{buttons.map((button, index) => ( @@ -64,7 +51,7 @@ const AnnotationPopup: React.FC = ({ /> ))}
-
+ {highlightOptionsVisible && ( = ({ left: `${position.point.x}px`, top: `${ position.point.y + - (highlightOptionsHeightPx + highlightOptionsPaddingPx) * (isPopupAbove ? -1 : 1) + (highlightOptionsHeightPx + highlightOptionsPaddingPx) * + (trianglePosition.dir === 'up' ? -1 : 1) }px`, }} selectedStyle={selectedStyle} diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index f67b7dd9..65e503b1 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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(); 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(); const [annotPopupPosition, setAnnotPopupPosition] = useState(); const [dictPopupPosition, setDictPopupPosition] = useState(); @@ -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 (
- {(showAnnotPopup || showWiktionaryPopup) && !notebookNewAnnotation && ( -
- )} + {(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) && + !notebookNewAnnotation && ( +
+ )} {showWiktionaryPopup && trianglePosition && dictPopupPosition && ( = ({ bookKey }) => { popupHeight={dictPopupHeight} /> )} + {showWikipediaPopup && trianglePosition && dictPopupPosition && ( + + )} + {showDeepLPopup && trianglePosition && dictPopupPosition && ( + + )} {showAnnotPopup && trianglePosition && annotPopupPosition && ( = ({ + text, + position, + trianglePosition, + popupWidth, + popupHeight, +}) => { + const [sourceLang, setSourceLang] = useState('AUTO'); + const [targetLang, setTargetLang] = useState('EN'); + const [translation, setTranslation] = useState(null); + const [detectedSourceLang, setDetectedSourceLang] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSourceLangChange = (event: React.ChangeEvent) => { + setSourceLang(event.target.value); + }; + + const handleTargetLangChange = (event: React.ChangeEvent) => { + 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 ( +
+ +
+
+

Original Text

+ +
+

{text}

+
+ +
+
+

Translated Text

+ +
+ + {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( +
+

{translation || 'No translation available.'}

+
+ Translated by{' '} + + DeepL + + . +
+
+ )} +
+
+
+ ); +}; + +export default DeepLPopup; diff --git a/apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx new file mode 100644 index 00000000..e5d7bbf9 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/WikipediaPopup.tsx @@ -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 = ({ + 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 Wikipedia.`; + + 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 ( +
+ +
+ +
+
+ ); +}; + +export default WikipediaPopup; 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 e9f78c7a..d6b73be4 100644 --- a/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx @@ -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 = ({ +const WiktionaryPopup: React.FC = ({ word, lang, position, @@ -39,7 +40,7 @@ const WiktionaryPopup: React.FC = ({ const links = container.querySelectorAll('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 = ({ return (
-
-
-
+
); }; diff --git a/apps/readest-app/src/components/Popup.tsx b/apps/readest-app/src/components/Popup.tsx new file mode 100644 index 00000000..a9cc2bdc --- /dev/null +++ b/apps/readest-app/src/components/Popup.tsx @@ -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; +}) => ( +
+
+
+ {children} +
+
+); + +export default Popup;