diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 9c13eb11..c1b82738 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -108,7 +108,7 @@ const FoliateViewer: React.FC<{ }); useEffect(() => { - if (viewRef.current) { + if (viewRef.current && viewRef.current.renderer) { const viewSettings = getViewSettings(bookKey)!; viewRef.current.renderer.setStyles?.(getStyles(viewSettings, themeCode)); } 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 9be9e306..f67b7dd9 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -9,30 +9,35 @@ import { RiDeleteBinLine } from 'react-icons/ri'; import { Overlayer } from 'foliate-js/overlayer.js'; import { useEnv } from '@/context/EnvContext'; import { BookNote, HighlightColor, HighlightStyle } from '@/types/book'; +import { uniqueId } from '@/utils/misc'; import { useReaderStore } from '@/store/readerStore'; import { useFoliateEvents } from '../../hooks/useFoliateEvents'; import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; import Toast from '@/components/Toast'; import AnnotationPopup from './AnnotationPopup'; -import { uniqueId } from '@/utils/misc'; +import WiktionaryPopup from './WiktionaryPopup'; const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const { envConfig } = useEnv(); const { settings, saveConfig, getProgress, updateBooknotes } = useReaderStore(); - const { getConfig, getView, getViewsById } = useReaderStore(); + const { getConfig, getBookData, getView, getViewsById } = useReaderStore(); const { notebookNewAnnotation, setNotebookVisible, setNotebookNewAnnotation } = useReaderStore(); const globalReadSettings = settings.globalReadSettings; const config = getConfig(bookKey)!; const progress = getProgress(bookKey)!; + const bookData = getBookData(bookKey)!; const view = getView(bookKey); const isShowingPopup = useRef(false); const isTextSelected = useRef(false); const [selection, setSelection] = useState(); - const [showPopup, setShowPopup] = useState(false); + const [showAnnotPopup, setShowAnnotPopup] = useState(false); + const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false); + const [wiktionaryWord, setWiktionaryWord] = useState(''); const [trianglePosition, setTrianglePosition] = useState(); - const [popupPosition, setPopupPosition] = useState(); + const [annotPopupPosition, setAnnotPopupPosition] = useState(); + const [dictPopupPosition, setDictPopupPosition] = useState(); const [toastMessage, setToastMessage] = useState(''); const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false); @@ -43,8 +48,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { globalReadSettings.highlightStyles[selectedStyle], ); - const popupWidth = 240; - const popupHeight = 44; + const dictPopupWidth = 400; + const dictPopupHeight = 300; + const annotPopupWidth = 240; + const annotPopupHeight = 44; const popupPadding = 10; const onLoad = (event: Event) => { @@ -92,8 +99,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]); const handleDismissPopup = () => { - setShowPopup(false); setSelection(null); + setShowAnnotPopup(false); + setShowWiktionaryPopup(false); isShowingPopup.current = false; }; @@ -110,7 +118,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { isTextSelected.current = false; return true; } - if (showPopup || isShowingPopup.current) { + if (showAnnotPopup || isShowingPopup.current) { return true; } return false; @@ -129,10 +137,24 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { if (!gridFrame) return; const rect = gridFrame.getBoundingClientRect(); const triangPos = getPosition(selection.range, rect); - const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding); + const annotPopupPos = getPopupPosition( + triangPos, + rect, + annotPopupWidth, + annotPopupHeight, + popupPadding, + ); + const dictPopupPos = getPopupPosition( + triangPos, + rect, + dictPopupWidth, + dictPopupHeight, + popupPadding, + ); if (triangPos.point.x == 0 || triangPos.point.y == 0) return; - setShowPopup(true); - setPopupPosition(popupPos); + setShowAnnotPopup(true); + setAnnotPopupPosition(annotPopupPos); + setDictPopupPosition(dictPopupPos); setTrianglePosition(triangPos); isShowingPopup.current = true; } @@ -145,7 +167,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const handleCopy = () => { if (!selection || !selection.text) return; - setShowPopup(false); + setShowAnnotPopup(false); setToastMessage('Copied to notebook'); const { booknotes: annotations = [] } = config; @@ -210,7 +232,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { views.forEach((view) => view?.addAnnotation(annotation)); } else { annotations.splice(existingIndex, 1); - setShowPopup(false); + setShowAnnotPopup(false); } } else { annotations.push(annotation); @@ -236,11 +258,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const handleSearch = () => { if (!selection || !selection.text) return; - setShowPopup(false); + setShowAnnotPopup(false); eventDispatcher.dispatch('search', { term: selection.text }); }; - const handleDictionary = () => {}; + const handleDictionary = () => { + if (!selection || !selection.text) return; + setShowAnnotPopup(false); + setShowWiktionaryPopup(true); + setWiktionaryWord(selection.text); + }; const selectionAnnotated = selection?.annotated; const buttons = [ @@ -257,23 +284,33 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { return (
- {showPopup && !notebookNewAnnotation && ( + {(showAnnotPopup || showWiktionaryPopup) && !notebookNewAnnotation && (
)} - {showPopup && trianglePosition && popupPosition && ( + {showWiktionaryPopup && trianglePosition && dictPopupPosition && ( + + )} + {showAnnotPopup && trianglePosition && annotPopupPosition && ( )} diff --git a/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx new file mode 100644 index 00000000..e9f78c7a --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/WiktionaryPopup.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Position } from '@/utils/sel'; + +type Definition = { + definition: string; + examples?: string[]; +}; + +type Result = { + partOfSpeech: string; + definitions: Definition[]; + language: string; +}; + +interface WiktionaryProps { + word: string; + lang?: string; + position: Position; + trianglePosition: Position; + popupWidth: number; + popupHeight: number; +} + +const WiktionaryPopup: React.FC = ({ + word, + lang, + position, + trianglePosition, + popupWidth, + popupHeight, +}) => { + const [lookupWord, setLookupWord] = useState(word); + const isLookingUp = useRef(false); + + const interceptDictLinks = (definition: string): HTMLElement[] => { + const container = document.createElement('div'); + container.innerHTML = definition; + + const links = container.querySelectorAll('a[rel="mw:WikiLink"]'); + + links.forEach((link) => { + const title = link.getAttribute('title'); // Get the title attribute + if (title) { + link.addEventListener('click', (event) => { + event.preventDefault(); + setLookupWord(title); + isLookingUp.current = false; + }); + + link.className = 'text-primary underline cursor-pointer'; + } + }); + + return Array.from(container.childNodes) as HTMLElement[]; + }; + + useEffect(() => { + if (isLookingUp.current) { + return; + } + isLookingUp.current = true; + const main = document.querySelector('main') as HTMLElement; + const footer = document.querySelector('footer') as HTMLElement; + + const fetchDefinitions = async (word: string, language?: string) => { + main.innerHTML = ''; + footer.dataset['state'] = 'loading'; + + try { + const response = await fetch( + `https://en.wiktionary.org/api/rest_v1/page/definition/${word}`, + ); + if (!response.ok) { + throw new Error('Failed to fetch definitions'); + } + + const json = await response.json(); + const results: Result[] | undefined = language + ? json[language] || json['en'] + : json[Object.keys(json)[0]!]; + + if (!results || results.length === 0) { + throw new Error('No results found'); + } + + const hgroup = document.createElement('hgroup'); + const h1 = document.createElement('h1'); + h1.innerText = word; + h1.className = 'text-lg font-bold'; + + const p = document.createElement('p'); + p.innerText = results[0]!.language; + p.className = 'text-sm italic opacity-75'; + hgroup.append(h1, p); + main.append(hgroup); + + results.forEach(({ partOfSpeech, definitions }: Result) => { + const h2 = document.createElement('h2'); + h2.innerText = partOfSpeech; + h2.className = 'text-base font-semibold mt-4'; + + const ol = document.createElement('ol'); + ol.className = 'pl-8 list-decimal'; + + definitions.forEach(({ definition, examples }: Definition) => { + if (!definition) return; + const li = document.createElement('li'); + const processedContent = interceptDictLinks(definition); + li.append(...processedContent); + + if (examples) { + const ul = document.createElement('ul'); + ul.className = 'pl-8 list-disc text-sm italic opacity-75'; + + examples.forEach((example) => { + const exampleLi = document.createElement('li'); + exampleLi.innerHTML = example; + ul.appendChild(exampleLi); + }); + + li.appendChild(ul); + } + + ol.appendChild(li); + }); + + main.appendChild(h2); + main.appendChild(ol); + }); + + footer.dataset['state'] = 'loaded'; + } catch (error) { + console.error(error); + footer.dataset['state'] = 'error'; + + const div = document.createElement('div'); + div.className = + 'flex flex-col items-center justify-center w-full h-full text-center absolute inset-0'; + + const h1 = document.createElement('h1'); + h1.innerText = 'Error'; + h1.className = 'text-lg font-bold'; + + const p = document.createElement('p'); + p.innerHTML = `Unable to load the word. Try searching directly on Wiktionary.`; + + div.append(h1, p); + main.append(div); + } + }; + + const langCode = typeof lang === 'string' ? lang : lang?.[0]; + fetchDefinitions(lookupWord, langCode); + }, [lookupWord, lang]); + + return ( +
+
+
+
+ +
+
+ ); +}; + +export default WiktionaryPopup; diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts index 6a8bf5f7..b09e4bf6 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -1,4 +1,4 @@ -const doubleClickThreshold = 200; +const doubleClickThreshold = 250; const longHoldThreshold = 500; let lastClickTime = 0; let longHoldTimeout: ReturnType | null = null; diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index 72ceb0bb..8da6e914 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -43,6 +43,7 @@ export interface BookDoc { metadata: { title: string; author: string; + language: string | string[]; editor?: string; publisher?: string; };