diff --git a/enjoy/src/commands/lookup.command.ts b/enjoy/src/commands/lookup.command.ts index 3e72acc9..364095ce 100644 --- a/enjoy/src/commands/lookup.command.ts +++ b/enjoy/src/commands/lookup.command.ts @@ -53,54 +53,19 @@ export const lookupCommand = async ( return jsonCommand(prompt, { ...options, schema }); }; -const DICITIONARY_PROMPT = `You are an {learning_language}-{native_language} dictionary. I will provide "word(it also maybe a phrase)" and "context" as input, you should return the "word", "lemma", "pronunciation", "pos(part of speech, maybe empty for phrase)", "definition", "translation" and "context_translation" as output. If I provide "definitions", you should try to select the appropriate one for the given context, and return the id of selected definition as "id". If none are suitable, generate a new definition for me. If no context is provided, return the most common definition. If you do not know the appropriate definition, return an empty string for "definition" and "translation". - Always return output in JSON format. +const DICITIONARY_PROMPT = `You are an {learning_language}-{native_language} dictionary. +I will provide "word(it also maybe a phrase)" and "context" as input, you should return the "word", "lemma", "pronunciation", "pos", "definition", "translation" and "context_translation" as output. +If no context is provided, return the most common definition. +If you do not know the appropriate definition, return an empty string for "definition" and "translation". +Always return output in JSON format. - # Example 1, with empty definitions - - {{ - "word": "booked", - "context": "She'd *booked* a table for four at their favourite restaurant.", - "definitions": [] - }} - - - - {{ - "word": "booked", - "lemma": "book", - "pronunciation": "bʊk", - "pos": "verb", - "definition": "to arrange to have a seat, room, performer, etc. at a particular time in the future", - "translation": "预订", - "context_translation": "她已经在他们最喜欢的餐厅预订了四人桌位。" - }} - - - # Example 2, with definitions - - {{ - "word": "booked", - "context": "She'd *booked* a table for four at their favourite restaurant.", - "definitions": [ - {{ - "id": "767ddbf3-c08a-42e1-95c8-c48e681f3486", - "pos": "noun", - "definition": "a written text that can be published in printed or electronic form", - }}, - {{ - "id": "37940295-ef93-4873-af60-f03bf7e271f0", - "pos": "verb", - "definition": "to arrange to have a seat, room, performer, etc. at a particular time in the future", - }} - ] - }} - - - - {{ - "id": "37940295-ef93-4873-af60-f03bf7e271f0", - "context_translation": "她已经在他们最喜欢的餐厅预订了四人桌位。" - }} - - `; +The output format: +{{ + "word": "the original word or phrase", + "lemma": "lemma", + "pronunciation": "IPA pronunciation", + "pos": "the part of speech", + "definition": "the definition in {learning_language}", + "translation": "translation in {native_language}", + "context_translation": "translation of the context in {native_language}", +}}`; diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index d87a284c..4c3cc896 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -423,8 +423,8 @@ "extracting": "Extracting", "extractionFailed": "Extraction failed", "extractedSuccessfully": "Extracted successfully", - "lookUp": "Look up", - "lookUpAll": "Look up all", + "lookup": "Look up", + "lookupAll": "Look up all", "lookingUp": "Looking up", "pending": "Pending", "thereAreLookupsProcessing": "There are {{count}} lookups processing", @@ -532,13 +532,14 @@ "analysis": "Analysis", "note": "Note" }, - "translateSetence": "translate setenece", + "translateSentence": "translate sentenece", "reTranslate": "re-translate", - "analyzeSetence": "analyze setenece", + "analyzeSentence": "analyze sentenece", "useAIAssistantToAnalyze": "Use AI assistant to analyze", "reAnalyze": "re-analyze", - "AiDictionary": "AI dictionary", - "AiTranslate": "AI translate", + "aiDictionary": "AI dictionary", + "aiLookup": "AI lookup", + "aiTranslate": "AI translate", "cambridgeDictionary": "Cambridge dictionary", "customizeShortcuts": "Customize shortcuts", "customizeShortcutsTip": "Click to change", @@ -571,5 +572,6 @@ "editTranscription": "Edit transcription", "saveTranscription": "Save transcription", "areYouSureToSaveTranscription": "It will perform a force-alignment between the audio and your edited transcription. Are you sure to continue?", - "summarize": "Summarize" + "summarize": "Summarize", + "noResultsFound": "No results found" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 487e4e1a..a5ead21e 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -422,8 +422,8 @@ "extracting": "正在提取", "extractionFailed": "提取失败", "extractedSuccessfully": "提取成功", - "lookUp": "查询", - "lookUpAll": "全部查询", + "lookup": "查询", + "lookupAll": "全部查询", "lookingUp": "正在查询", "pending": "等待中", "thereAreLookupsProcessing": "有{{count}}个单词正在查询", @@ -531,13 +531,14 @@ "analysis": "句子分析", "note": "笔记" }, - "translateSetence": "整句翻译", + "translateSentence": "整句翻译", "reTranslate": "重新翻译", - "analyzeSetence": "分析句子", + "analyzeSentence": "分析句子", "useAIAssistantToAnalyze": "使用智能助手分析", "reAnalyze": "重新分析", - "AiDictionary": "智能词典", - "AiTranslate": "智能翻译", + "aiDictionary": "智能词典", + "aiLookup": "查询智能词典", + "aiTranslate": "智能翻译", "cambridgeDictionary": "剑桥词典", "customizeShortcuts": "自定义快捷键", "customizeShortcutsTip": "点击重新录制", @@ -570,5 +571,6 @@ "editTranscription": "编辑语音文本", "saveTranscription": "保存语音文本", "areYouSureToSaveTranscription": "即将根据您修改后的语音文本对语音重新进行对齐,确定要继续吗?", - "summarize": "提炼主题" + "summarize": "提炼主题", + "noResultsFound": "没有找到结果" } diff --git a/enjoy/src/main.ts b/enjoy/src/main.ts index 056069f0..df8568bf 100644 --- a/enjoy/src/main.ts +++ b/enjoy/src/main.ts @@ -49,6 +49,33 @@ contextMenu({ shouldShowMenu: (_event, params) => { return params.isEditable || !!params.selectionText; }, + prepend: ( + _defaultActions, + parameters, + browserWindow: BrowserWindow, + _event + ) => [ + { + label: t("lookup"), + visible: + parameters.selectionText.trim().length > 0 && + !parameters.selectionText.trim().includes(" "), + click: () => { + const { x, y, selectionText } = parameters; + browserWindow.webContents.send("on-lookup", selectionText, { x, y }); + }, + }, + { + label: t("aiTranslate"), + visible: + parameters.selectionText.trim().length > 0 && + parameters.selectionText.trim().includes(" "), + click: () => { + const { x, y, selectionText } = parameters; + browserWindow.webContents.send("on-translate", selectionText, { x, y }); + }, + }, + ], }); // Handle creating/removing shortcuts on Windows when installing/uninstalling. diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index bf11d2e0..8fa00e5b 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -123,6 +123,23 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { onNotification: ( callback: (event: IpcRendererEvent, notification: NotificationType) => void ) => ipcRenderer.on("on-notification", callback), + onLookup: ( + callback: ( + event: IpcRendererEvent, + selection: string, + position: { x: number; y: number } + ) => void + ) => ipcRenderer.on("on-lookup", callback), + offLookup: () => { + ipcRenderer.removeAllListeners("on-lookup"); + }, + onTranslate: ( + callback: ( + event: IpcRendererEvent, + selection: string, + position: { x: number; y: number } + ) => void + ) => ipcRenderer.on("on-translate", callback), shell: { openExternal: (url: string) => ipcRenderer.invoke("shell-open-external", url), diff --git a/enjoy/src/renderer/app.tsx b/enjoy/src/renderer/app.tsx index 3075d680..fa7e668d 100644 --- a/enjoy/src/renderer/app.tsx +++ b/enjoy/src/renderer/app.tsx @@ -9,6 +9,7 @@ import router from "./router"; import { RouterProvider } from "react-router-dom"; import { Toaster, toast } from "@renderer/components/ui"; import { Tooltip } from "react-tooltip"; +import { LookupWidget, TranslateWidget } from "./components"; function App() { window.__ENJOY_APP__.onNotification((_event, notification) => { @@ -40,6 +41,8 @@ function App() { + + diff --git a/enjoy/src/renderer/components/index.ts b/enjoy/src/renderer/components/index.ts index 4c69d9a1..ce504d73 100644 --- a/enjoy/src/renderer/components/index.ts +++ b/enjoy/src/renderer/components/index.ts @@ -3,6 +3,7 @@ export * from "./conversations"; export * from "./meanings"; export * from "./messages"; export * from "./medias"; +export * from "./misc"; export * from "./notes"; export * from "./posts"; export * from "./preferences"; diff --git a/enjoy/src/renderer/components/medias/media-captions/tab-content-analysis.tsx b/enjoy/src/renderer/components/medias/media-captions/tab-content-analysis.tsx index 6342f346..1b588179 100644 --- a/enjoy/src/renderer/components/medias/media-captions/tab-content-analysis.tsx +++ b/enjoy/src/renderer/components/medias/media-captions/tab-content-analysis.tsx @@ -17,7 +17,7 @@ export function TabContentAnalysis(props: { text: string; }) { const { analyzeText } = useAiCommand(); - const analyzeSetence = async () => { + const analyzeSentence = async () => { if (analyzing) return; setAnalyzing(true); @@ -69,7 +69,7 @@ export function TabContentAnalysis(props: { text: string; }) { variant="secondary" size="sm" disabled={analyzing} - onClick={analyzeSetence} + onClick={analyzeSentence} > {analyzing && ( @@ -88,9 +88,9 @@ export function TabContentAnalysis(props: { text: string; }) { ) : (
- (); - const [translating, setTranslating] = useState(false); - const { translate } = useAiCommand(); - - const translateSetence = async () => { - if (translating) return; - - setTranslating(true); - translate(caption.text, `translate-${md5(caption.text)}`) - .then((result) => { - if (result) { - setTranslation(result); - } - }) - .catch((err) => toast.error(err.message)) - .finally(() => { - setTranslating(false); - }); - }; - - /* - * If the caption is changed, then reset the translation. - * Also, check if the translation is cached, then use it. - */ - useEffect(() => { - EnjoyApp.cacheObjects - .get(`translate-${md5(caption.text)}`) - .then((cached) => { - setTranslation(cached); - }); - }, [caption.text]); return ( - - - - {translation ? ( -
-
- {t("translateSetence")} -
- - {translation} - - -
- -
-
- ) : ( -
- -
- )} + +
+ {t("translateSentence")} +
+
); } @@ -107,75 +38,11 @@ const SelectedWords = (props: { const { selectedIndices, caption } = props; const { transcription } = useContext(MediaPlayerProviderContext); - const { webApi } = useContext(AppSettingsProviderContext); - const [lookingUp, setLookingUp] = useState(false); - const [lookupResult, setLookupResult] = useState(); - - const lookup = () => { - if (selectedIndices.length === 0) return; - - const word = selectedIndices - .map((index) => caption.timeline[index]?.text || "") - .join(" "); - if (!word) return; - - setLookingUp(true); - lookupWord({ - word, - context: caption.text, - sourceId: transcription.targetId, - sourceType: transcription.targetType, - }) - .then((res) => { - if (res?.meaning) { - setLookupResult(res); - } - }) - .catch((error) => { - toast.error(error.message); - }) - .finally(() => { - setLookingUp(false); - }); - }; - - const { lookupWord } = useAiCommand(); - const { result: camdictResult } = useCamdict( - selectedIndices - .map((index) => caption?.timeline?.[index]?.text || "") - .join(" ") - .trim() - ); - - /* - * If the selected indices are changed, then reset the lookup result. - */ - useEffect(() => { - if (!caption) return; - if (!selectedIndices) return; - - const word = selectedIndices - .map((index) => caption.timeline[index]?.text || "") - .join(" "); - - if (!word) return; - - webApi - .lookup({ - word, - context: caption.text, - sourceId: transcription.targetId, - sourceType: transcription.targetType, - }) - .then((res) => { - if (res?.meaning) { - setLookupResult(res); - } else { - setLookupResult(null); - } - }); - }, [caption, selectedIndices]); + const word = selectedIndices + .map((index) => caption.timeline[index]?.text || "") + .join(" ") + .trim(); if (selectedIndices.length === 0) return ( @@ -222,104 +89,16 @@ const SelectedWords = (props: { })}
- {camdictResult && ( - <> - -
- {t("cambridgeDictionary")} -
-
- {camdictResult.posItems.map((posItem, index) => ( -
-
-
- {posItem.type} -
- - {posItem.pronunciations.map((pron, i) => ( -
- - [{pron.region}] - - - /{pron.pronunciation}/ - - {pron.audio && ( -
- -
- )} -
- ))} -
-
    - {posItem.definitions.map((def, i) => ( -
  • - {def.definition} -
  • - ))} -
-
- ))} -
- - )} + + -
- {t("AiDictionary")} -
- {lookupResult ? ( -
-
- {lookupResult.meaning?.pos && ( - - {lookupResult.meaning.pos} - - )} - {lookupResult.meaning?.pronunciation && ( - - /{lookupResult.meaning.pronunciation}/ - - )} - {lookupResult.meaning?.lemma && - lookupResult.meaning.lemma !== lookupResult.meaning.word && ( - ({lookupResult.meaning.lemma}) - )} -
-
{lookupResult.meaning.translation}
-
{lookupResult.meaning.definition}
-
- ) : ( -
- -
- )} + ); }; diff --git a/enjoy/src/renderer/components/messages/assistant-message.tsx b/enjoy/src/renderer/components/messages/assistant-message.tsx index 06f9b23b..fbfc90bd 100644 --- a/enjoy/src/renderer/components/messages/assistant-message.tsx +++ b/enjoy/src/renderer/components/messages/assistant-message.tsx @@ -172,6 +172,8 @@ export const AssistantMessageComponent = (props: { {configuration.type === "gpt" && ( ; diff --git a/enjoy/src/renderer/components/stories/story-viewer.tsx b/enjoy/src/renderer/components/stories/story-viewer.tsx index 2cc29cf7..20742732 100644 --- a/enjoy/src/renderer/components/stories/story-viewer.tsx +++ b/enjoy/src/renderer/components/stories/story-viewer.tsx @@ -1,16 +1,9 @@ -import { useState, useEffect, useContext, useRef } from "react"; +import { useEffect, useContext, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { AppSettingsProviderContext } from "@renderer/context"; import { ChevronLeftIcon, ExternalLinkIcon } from "lucide-react"; -import { - Button, - Popover, - PopoverContent, - PopoverAnchor, -} from "@renderer/components/ui"; -import { SelectionMenu } from "@renderer/components"; +import { Button } from "@renderer/components/ui"; import uniq from "lodash/uniq"; -import debounce from "lodash/debounce"; import Mark from "mark.js"; export const StoryViewer = (props: { @@ -22,14 +15,7 @@ export const StoryViewer = (props: { doc: any; }) => { const navigate = useNavigate(); - const { - story, - marked, - meanings = [], - setMeanings, - pendingLookups = [], - doc, - } = props; + const { story, marked, meanings = [], pendingLookups = [], doc } = props; if (!story || !doc) return null; const paragraphs: { terms: any[]; text: string }[][] = doc @@ -38,46 +24,6 @@ export const StoryViewer = (props: { const { EnjoyApp } = useContext(AppSettingsProviderContext); const ref = useRef(); - const [selected, setSelected] = useState<{ - word: string; - context?: string; - position?: { - top: number; - left: number; - }; - }>(); - - const handleSelectionChanged = debounce(() => { - const selection = document.getSelection(); - if (!selection?.anchorNode?.parentElement) return; - if (!ref.current?.contains(selection.anchorNode.parentElement)) return; - - const word = selection - .toString() - .trim() - .replace(/[.,/#!$%^&*;:{}=\-_`~()]+$/, ""); - if (!word) return; - - const position = { - top: - selection.anchorNode.parentElement.offsetTop + - selection.anchorNode.parentElement.offsetHeight, - left: selection.anchorNode.parentElement.offsetLeft, - }; - const context = selection.anchorNode.parentElement - .closest("span.sentence, h2") - ?.textContent?.trim(); - - setSelected({ word, context, position }); - }, 500); - - useEffect(() => { - document.addEventListener("selectionchange", handleSelectionChanged); - - return () => { - document.removeEventListener("selectionchange", handleSelectionChanged); - }; - }, [story, ref]); useEffect(() => { const marker = new Mark(ref.current); @@ -141,6 +87,8 @@ export const StoryViewer = (props: {

{story.title.split(" ").map((word, i) => ( @@ -176,39 +124,6 @@ export const StoryViewer = (props: { })}

))} - - { - if (!value) setSelected(null); - }} - > - - - {selected?.word && ( - { - if (setMeanings) { - setMeanings([...meanings, meaning]); - } - }} - /> - )} - -

diff --git a/enjoy/src/renderer/components/stories/story-vocabulary-sheet.tsx b/enjoy/src/renderer/components/stories/story-vocabulary-sheet.tsx index f204966e..2fc91108 100644 --- a/enjoy/src/renderer/components/stories/story-vocabulary-sheet.tsx +++ b/enjoy/src/renderer/components/stories/story-vocabulary-sheet.tsx @@ -97,7 +97,7 @@ export const StoryVocabularySheet = (props: { onClick={() => setLookupInBatch(true)} size="sm" > - {t("lookUpAll")} + {t("lookupAll")} @@ -125,7 +125,7 @@ export const StoryVocabularySheet = (props: { variant="secondary" size="sm" > - {t("lookUp")} + {t("lookup")}
diff --git a/enjoy/src/renderer/components/widgets/index.ts b/enjoy/src/renderer/components/widgets/index.ts index febfada0..2c08c43e 100644 --- a/enjoy/src/renderer/components/widgets/index.ts +++ b/enjoy/src/renderer/components/widgets/index.ts @@ -1,10 +1,2 @@ -export * from "./db-state"; -export * from "./layout"; -export * from "./loader-spin"; -export * from "./lookup-result"; -export * from "./login-form"; -export * from "./no-records-found"; -export * from "./page-placeholder"; -export * from "./selection-menu"; -export * from "./sidebar"; -export * from "./wavesurfer-player"; +export * from "./lookup-widget"; +export * from "./translate-widget"; diff --git a/enjoy/src/renderer/components/widgets/lookup-result.tsx b/enjoy/src/renderer/components/widgets/lookup-result.tsx deleted file mode 100644 index c29a621e..00000000 --- a/enjoy/src/renderer/components/widgets/lookup-result.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState, useEffect } from "react"; -import { LoaderSpin, MeaningCard } from "@renderer/components"; -import { Button, toast } from "@renderer/components/ui"; -import { t } from "i18next"; -import { XCircleIcon } from "lucide-react"; -import { useAiCommand } from "@renderer/hooks"; - -export const LookupResult = (props: { - word: string; - context?: string; - sourceId?: string; - sourceType?: string; - onResult?: (meaning: MeaningType) => void; -}) => { - const { word, context, sourceId, sourceType, onResult } = props; - const [result, setResult] = useState(); - const [loading, setLoading] = useState(false); - if (!word) return null; - - const { lookupWord } = useAiCommand(); - - const processLookup = async () => { - if (!word) return; - if (loading) return; - - setLoading(true); - lookupWord({ - word, - context, - sourceId, - sourceType, - }) - .then((lookup) => { - if (lookup?.meaning) { - setResult(lookup); - onResult && onResult(lookup.meaning); - } - }) - .catch((error) => { - toast.error(error.message); - }) - .finally(() => { - setLoading(false); - }); - }; - - useEffect(() => { - processLookup(); - }, [word, context]); - - if (result?.meaning) { - return ( -
- -
- ); - } - - if (loading) { - return ( -
-
{word}
- -
- ); - } - - if (result?.status === "failed") { - return ( -
-
{word}
-
-
- -
-
- {t("pleaseTryLater")} -
-
-
- ); - } - - return ( -
-
{word}
-
- -
-
- ); -}; diff --git a/enjoy/src/renderer/components/widgets/lookup-widget.tsx b/enjoy/src/renderer/components/widgets/lookup-widget.tsx new file mode 100644 index 00000000..adbd479c --- /dev/null +++ b/enjoy/src/renderer/components/widgets/lookup-widget.tsx @@ -0,0 +1,285 @@ +import { useEffect, useContext, useState } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { + Button, + Popover, + PopoverAnchor, + PopoverContent, + ScrollArea, + Separator, + toast, +} from "@renderer/components/ui"; +import { useAiCommand, useCamdict } from "@renderer/hooks"; +import { LoaderIcon, Volume2Icon } from "lucide-react"; +import { t } from "i18next"; + +export const LookupWidget = () => { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState<{ + word: string; + context?: string; + sourceType?: string; + sourceId?: string; + position: { + x: number; + y: number; + }; + }>(); + + const handleSelectionChanged = (position: { x: number; y: number }) => { + const selection = document.getSelection(); + if (!selection?.anchorNode?.parentElement) return; + + const word = selection + .toString() + .trim() + .replace(/[.,/#!$%^&*;:{}=\-_`~()]+$/, ""); + if (!word) return; + // can only lookup single word + if (word.indexOf(" ") > -1) return; + + const context = selection.anchorNode.parentElement + .closest(".sentence, h2, p, div") + ?.textContent?.trim(); + + const sourceType = selection.anchorNode.parentElement + .closest("[data-source-type]") + ?.getAttribute("data-source-type"); + const sourceId = selection.anchorNode.parentElement + .closest("[data-source-id]") + ?.getAttribute("data-source-id"); + + setSelected({ word, context, position, sourceType, sourceId }); + setOpen(true); + }; + + useEffect(() => { + EnjoyApp.onLookup((_event, _selection, position) => { + handleSelectionChanged(position); + }); + + return () => EnjoyApp.offLookup(); + }, []); + + return ( + + + + {selected?.word && ( + +
+ {selected?.word} +
+
+ + + +
+
+ )} +
+
+ ); +}; + +export const AiLookupResult = (props: { + word: string; + context?: string; + sourceType?: string; + sourceId?: string; +}) => { + const { word, context = "", sourceType, sourceId } = props; + const { webApi } = useContext(AppSettingsProviderContext); + + const [lookingUp, setLookingUp] = useState(false); + const [result, setResult] = useState(); + const { lookupWord } = useAiCommand(); + + const handleLookup = async () => { + if (lookingUp) return; + if (!word) return; + + setLookingUp(true); + lookupWord({ + word, + context, + sourceId, + sourceType, + }) + .then((lookup) => { + if (lookup?.meaning) { + setResult(lookup); + } + }) + .catch((error) => { + toast.error(error.message); + }) + .finally(() => { + setLookingUp(false); + }); + }; + + /* + * Fetch cached lookup result. + */ + useEffect(() => { + if (!word) return; + + webApi + .lookup({ + word, + context, + sourceId, + sourceType, + }) + .then((res) => { + if (res?.meaning) { + setResult(res); + } else { + setResult(null); + } + }); + }, [word, context]); + + if (!word) return null; + + return ( + <> +
+ {t("aiDictionary")} +
+ {result ? ( +
+
{word}
+
+ {result.meaning?.pos && ( + + {result.meaning.pos} + + )} + {result.meaning?.pronunciation && ( + + /{result.meaning.pronunciation.replaceAll("/", "")}/ + + )} + {result.meaning?.lemma && + result.meaning.lemma !== result.meaning.word && ( + ({result.meaning.lemma}) + )} +
+
{result.meaning.translation}
+
{result.meaning.definition}
+
+ ) : ( + + )} + + ); +}; + +export const CamdictLookupResult = (props: { word: string }) => { + const { word } = props; + const { result } = useCamdict(word); + + if (!word) return null; + + return ( + <> +
+ {t("cambridgeDictionary")} +
+ {result ? ( +
+
{word}
+ {result.posItems.map((posItem, index) => ( +
+
+
+ {posItem.type} +
+ + {posItem.pronunciations.map((pron, i) => ( +
+ + [{pron.region}] + + + /{pron.pronunciation}/ + + {pron.audio && ( +
+ +
+ )} +
+ ))} +
+
    + {posItem.definitions.map((def, i) => ( +
  • + {def.definition} +
  • + ))} +
+
+ ))} +
+ ) : ( +
+ - {t("noResultsFound")} - +
+ )} + + ); +}; diff --git a/enjoy/src/renderer/components/widgets/selection-menu.tsx b/enjoy/src/renderer/components/widgets/selection-menu.tsx deleted file mode 100644 index 708b91a4..00000000 --- a/enjoy/src/renderer/components/widgets/selection-menu.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { LanguagesIcon } from "lucide-react"; -import { Button } from "@renderer/components/ui"; -import { LookupResult } from "@renderer/components"; -import { useState } from "react"; - -export const SelectionMenu = (props: { - word: string; - context?: string; - sourceId?: string; - sourceType?: string; - onLookup?: (meaning: MeaningType) => void; -}) => { - const { word, context, sourceId, sourceType, onLookup } = props; - const [translating, setTranslating] = useState(false); - - if (!word) return null; - - if (translating) { - return ( - - ); - } - - return ( -
- -
- ); -}; diff --git a/enjoy/src/renderer/components/widgets/translate-widget.tsx b/enjoy/src/renderer/components/widgets/translate-widget.tsx new file mode 100644 index 00000000..0b38f35a --- /dev/null +++ b/enjoy/src/renderer/components/widgets/translate-widget.tsx @@ -0,0 +1,165 @@ +import { useEffect, useContext, useState } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { + Button, + Popover, + PopoverAnchor, + PopoverContent, + ScrollArea, + toast, +} from "@renderer/components/ui"; +import { useAiCommand } from "@renderer/hooks"; +import { LoaderIcon } from "lucide-react"; +import { t } from "i18next"; +import { md5 } from "js-md5"; + +export const TranslateWidget = () => { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState<{ + text: string; + position: { + x: number; + y: number; + }; + }>(); + + const handleSelectionChanged = (position: { x: number; y: number }) => { + const selection = document.getSelection(); + if (!selection?.anchorNode?.parentElement) return; + + const text = selection.toString().trim(); + if (!text) return; + + // if text is a single word, then return + if (text.indexOf(" ") === -1) return; + + setSelected({ text, position }); + setOpen(true); + }; + + useEffect(() => { + EnjoyApp.onTranslate((_event, _selection, position) => { + handleSelectionChanged(position); + }); + + return () => EnjoyApp.offLookup(); + }, []); + + return ( + + + + +
+ {selected?.text} +
+
+ +
+
+
+
+ ); +}; + +export const TranslateResult = (props: { + text: string; + autoTranslate?: boolean; +}) => { + const { text, autoTranslate = false } = props; + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const [translation, setTranslation] = useState(); + const [translating, setTranslating] = useState(false); + const { translate } = useAiCommand(); + + const handleTranslate = async () => { + if (translating) return; + if (!text) return; + + setTranslating(true); + translate(text, `translate-${md5(text)}`) + .then((result) => { + if (result) { + setTranslation(result); + } + }) + .catch((err) => toast.error(err.message)) + .finally(() => { + setTranslating(false); + }); + }; + + /* + * check if the translation is cached, then use it. + */ + useEffect(() => { + if (!text) return; + + EnjoyApp.cacheObjects.get(`translate-${md5(text)}`).then((cached) => { + if (cached) { + setTranslation(cached); + } else if (autoTranslate) { + handleTranslate(); + } else { + setTranslation(undefined); + } + }); + }, [text, autoTranslate]); + + if (!text) return null; + + return ( + <> + {translation ? ( + + ) : ( + + )} + + ); +}; diff --git a/enjoy/src/renderer/hooks/use-camdict.tsx b/enjoy/src/renderer/hooks/use-camdict.tsx index 2fff55cc..58dff7ee 100644 --- a/enjoy/src/renderer/hooks/use-camdict.tsx +++ b/enjoy/src/renderer/hooks/use-camdict.tsx @@ -1,7 +1,5 @@ import { useEffect, useContext, useState } from "react"; -import { - AppSettingsProviderContext, -} from "@renderer/context"; +import { AppSettingsProviderContext } from "@renderer/context"; export const useCamdict = (word: string) => { const { EnjoyApp } = useContext(AppSettingsProviderContext); diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index 78d7e517..de7ce63a 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -73,6 +73,21 @@ type EnjoyAppType = { onNotification: ( callback: (event, notification: NotificationType) => void ) => void; + onLookup: ( + callback: ( + event, + selection: string, + position: { x: number; y: number } + ) => void + ) => void; + offLookup: () => void; + onTranslate: ( + callback: ( + event, + selection: string, + position: { x: number; y: number } + ) => void + ) => void; shell: { openExternal: (url: string) => Promise; openPath: (path: string) => Promise;