Add wiktionary lookup

This commit is contained in:
chrox
2024-11-20 20:20:16 +01:00
parent 7c9ed815ad
commit e298e0bbe6
5 changed files with 253 additions and 22 deletions
@@ -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));
}
@@ -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<TextSelection | null>();
const [showPopup, setShowPopup] = useState(false);
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [wiktionaryWord, setWiktionaryWord] = useState('');
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [popupPosition, setPopupPosition] = useState<Position>();
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
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 (
<div>
{showPopup && !notebookNewAnnotation && (
{(showAnnotPopup || showWiktionaryPopup) && !notebookNewAnnotation && (
<div
className='fixed inset-0'
onClick={handleDismissPopupAndSelection}
onContextMenu={handleDismissPopup}
/>
)}
{showPopup && trianglePosition && popupPosition && (
{showWiktionaryPopup && trianglePosition && dictPopupPosition && (
<WiktionaryPopup
word={wiktionaryWord}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
/>
)}
{showAnnotPopup && trianglePosition && annotPopupPosition && (
<AnnotationPopup
buttons={buttons}
position={popupPosition}
position={annotPopupPosition}
trianglePosition={trianglePosition}
highlightOptionsVisible={highlightOptionsVisible}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
popupWidth={popupWidth}
popupHeight={popupHeight}
popupWidth={annotPopupWidth}
popupHeight={annotPopupHeight}
onHighlight={handleHighlight}
/>
)}
@@ -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<WiktionaryProps> = ({
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<HTMLAnchorElement>('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 <a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
word,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>.`;
div.append(h1, p);
main.append(div);
}
};
const langCode = typeof lang === 'string' ? lang : lang?.[0];
fetchDefinitions(lookupWord, langCode);
}, [lookupWord, lang]);
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`,
}}
>
<main className='p-4 font-sans' />
<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'>Wiktionary</a>, released under the{' '}
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
</div>
</footer>
</div>
</div>
);
};
export default WiktionaryPopup;
@@ -1,4 +1,4 @@
const doubleClickThreshold = 200;
const doubleClickThreshold = 250;
const longHoldThreshold = 500;
let lastClickTime = 0;
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
+1
View File
@@ -43,6 +43,7 @@ export interface BookDoc {
metadata: {
title: string;
author: string;
language: string | string[];
editor?: string;
publisher?: string;
};