From 0e8de4881c18c22a107433b9ed0cf5f593f8e2ab Mon Sep 17 00:00:00 2001
From: an-lee
Date: Fri, 10 May 2024 11:16:38 +0800
Subject: [PATCH] Feat: lookup in context menu (#595)
* add lookup widget
* add lookup result
* refactor
* refactor
* add translate widget
* make translate widget works
* refactor locales
* typo
* remove deprecated component
* refactor lookup prompt
---
enjoy/src/commands/lookup.command.ts | 65 +---
enjoy/src/i18n/en.json | 16 +-
enjoy/src/i18n/zh-CN.json | 16 +-
enjoy/src/main.ts | 27 ++
enjoy/src/preload.ts | 17 ++
enjoy/src/renderer/app.tsx | 3 +
enjoy/src/renderer/components/index.ts | 1 +
.../media-captions/tab-content-analysis.tsx | 8 +-
.../tab-content-translation.tsx | 271 ++---------------
.../components/messages/assistant-message.tsx | 2 +
.../components/{widgets => misc}/db-state.tsx | 0
enjoy/src/renderer/components/misc/index.ts | 8 +
.../components/{widgets => misc}/layout.tsx | 0
.../{widgets => misc}/loader-spin.tsx | 0
.../{widgets => misc}/login-form.tsx | 0
.../{widgets => misc}/no-records-found.tsx | 0
.../{widgets => misc}/page-placeholder.tsx | 0
.../components/{widgets => misc}/sidebar.tsx | 0
.../{widgets => misc}/wavesurfer-player.tsx | 0
.../components/notes/note-segment.tsx | 2 +-
.../renderer/components/posts/post-audio.tsx | 2 +-
.../components/stories/story-viewer.tsx | 95 +-----
.../stories/story-vocabulary-sheet.tsx | 4 +-
.../src/renderer/components/widgets/index.ts | 12 +-
.../components/widgets/lookup-result.tsx | 94 ------
.../components/widgets/lookup-widget.tsx | 285 ++++++++++++++++++
.../components/widgets/selection-menu.tsx | 37 ---
.../components/widgets/translate-widget.tsx | 165 ++++++++++
enjoy/src/renderer/hooks/use-camdict.tsx | 4 +-
enjoy/src/types/enjoy-app.d.ts | 15 +
30 files changed, 597 insertions(+), 552 deletions(-)
rename enjoy/src/renderer/components/{widgets => misc}/db-state.tsx (100%)
create mode 100644 enjoy/src/renderer/components/misc/index.ts
rename enjoy/src/renderer/components/{widgets => misc}/layout.tsx (100%)
rename enjoy/src/renderer/components/{widgets => misc}/loader-spin.tsx (100%)
rename enjoy/src/renderer/components/{widgets => misc}/login-form.tsx (100%)
rename enjoy/src/renderer/components/{widgets => misc}/no-records-found.tsx (100%)
rename enjoy/src/renderer/components/{widgets => misc}/page-placeholder.tsx (100%)
rename enjoy/src/renderer/components/{widgets => misc}/sidebar.tsx (100%)
rename enjoy/src/renderer/components/{widgets => misc}/wavesurfer-player.tsx (100%)
delete mode 100644 enjoy/src/renderer/components/widgets/lookup-result.tsx
create mode 100644 enjoy/src/renderer/components/widgets/lookup-widget.tsx
delete mode 100644 enjoy/src/renderer/components/widgets/selection-menu.tsx
create mode 100644 enjoy/src/renderer/components/widgets/translate-widget.tsx
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": []
- }}
-
-
-
-
- # 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",
- }}
- ]
- }}
-
-
-
- `;
+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; }) {
>
) : (
-
- {camdictResult && (
- <>
-
-
- {t("cambridgeDictionary")}
-
-
- {camdictResult.posItems.map((posItem, index) => (
-
-
-
- {posItem.type}
-
-
- {posItem.pronunciations.map((pron, i) => (
-
-
- [{pron.region}]
-
-
- /{pron.pronunciation}/
-
- {pron.audio && (
-
-
{
- const audio = document.getElementById(
- `${posItem.type}-${pron.region}`
- ) as HTMLAudioElement;
- if (audio) {
- audio.play();
- }
- }}
- >
-
-
-
-
- )}
-
- ))}
-
-
- {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}
-
- ) : (
-
-
- {lookingUp && }
- {t("AiTranslate")}
-
-
- )}
+
>
);
};
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 (
-
- );
- }
-
- if (result?.status === "failed") {
- return (
-
-
{word}
-
-
-
-
-
- {t("pleaseTryLater")}
-
-
-
- );
- }
-
- return (
-
-
{word}
-
-
- {t("retry")}
-
-
-
- );
-};
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 && (
+
+
{
+ const audio = document.getElementById(
+ `${posItem.type}-${pron.region}`
+ ) as HTMLAudioElement;
+ if (audio) {
+ audio.play();
+ }
+ }}
+ >
+
+
+
+
+ )}
+
+ ))}
+
+
+ {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 (
-
- setTranslating(true)} variant="ghost" size="icon">
-
-
-
- );
-};
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;