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
This commit is contained in:
@@ -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
|
||||
<input>
|
||||
{{
|
||||
"word": "booked",
|
||||
"context": "She'd *booked* a table for four at their favourite restaurant.",
|
||||
"definitions": []
|
||||
}}
|
||||
</input>
|
||||
|
||||
<output>
|
||||
{{
|
||||
"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": "她已经在他们最喜欢的餐厅预订了四人桌位。"
|
||||
}}
|
||||
</output>
|
||||
|
||||
# Example 2, with definitions
|
||||
<input>
|
||||
{{
|
||||
"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",
|
||||
}}
|
||||
]
|
||||
}}
|
||||
</input>
|
||||
|
||||
<output>
|
||||
{{
|
||||
"id": "37940295-ef93-4873-af60-f03bf7e271f0",
|
||||
"context_translation": "她已经在他们最喜欢的餐厅预订了四人桌位。"
|
||||
}}
|
||||
</output>
|
||||
`;
|
||||
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}",
|
||||
}}`;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "没有找到结果"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
<TranslateWidget />
|
||||
<LookupWidget />
|
||||
</DbProvider>
|
||||
</AISettingsProvider>
|
||||
</HotKeysSettingsProvider>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
@@ -88,9 +88,9 @@ export function TabContentAnalysis(props: { text: string; }) {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center space-x-2 py-4">
|
||||
<Button size="sm" disabled={analyzing} onClick={analyzeSetence}>
|
||||
<Button size="sm" disabled={analyzing} onClick={analyzeSentence}>
|
||||
{analyzing && <LoaderIcon className="animate-spin w-4 h-4 mr-2" />}
|
||||
<span>{t("analyzeSetence")}</span>
|
||||
<span>{t("analyzeSentence")}</span>
|
||||
</Button>
|
||||
<AIButton
|
||||
prompt={text as string}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { Button, toast, TabsContent, Separator } from "@renderer/components/ui";
|
||||
import { useContext } from "react";
|
||||
import { MediaPlayerProviderContext } from "@renderer/context";
|
||||
import { TabsContent, Separator } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useAiCommand, useCamdict } from "@renderer/hooks";
|
||||
import { LoaderIcon, Volume2Icon } from "lucide-react";
|
||||
import { md5 } from "js-md5";
|
||||
import Markdown from "react-markdown";
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline";
|
||||
import { convertIpaToNormal } from "@/utils";
|
||||
import {
|
||||
CamdictLookupResult,
|
||||
AiLookupResult,
|
||||
TranslateResult,
|
||||
} from "@renderer/components";
|
||||
|
||||
/*
|
||||
* Translation tab content.
|
||||
@@ -20,82 +18,15 @@ export function TabContentTranslation(props: {
|
||||
selectedIndices: number[];
|
||||
}) {
|
||||
const { caption } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [translation, setTranslation] = useState<string>();
|
||||
const [translating, setTranslating] = useState<boolean>(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 (
|
||||
<TabsContent value="translation">
|
||||
<SelectedWords {...props} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{translation ? (
|
||||
<div className="py-4">
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("translateSetence")}
|
||||
</div>
|
||||
<Markdown className="select-text prose dark:prose-invert prose-sm prose-h3:text-base max-w-full mb-2">
|
||||
{translation}
|
||||
</Markdown>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={translating}
|
||||
onClick={translateSetence}
|
||||
>
|
||||
{translating && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t("reTranslate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center py-4">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={translating}
|
||||
onClick={() => translateSetence()}
|
||||
>
|
||||
{translating && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
<span>{t("translateSetence")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("translateSentence")}
|
||||
</div>
|
||||
<TranslateResult text={caption.text} />
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -107,75 +38,11 @@ const SelectedWords = (props: {
|
||||
const { selectedIndices, caption } = props;
|
||||
|
||||
const { transcription } = useContext(MediaPlayerProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [lookingUp, setLookingUp] = useState<boolean>(false);
|
||||
const [lookupResult, setLookupResult] = useState<LookupType>();
|
||||
|
||||
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: {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{camdictResult && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("cambridgeDictionary")}
|
||||
</div>
|
||||
<div className="select-text">
|
||||
{camdictResult.posItems.map((posItem, index) => (
|
||||
<div key={index} className="mb-4">
|
||||
<div className="flex items-center space-x-4 mb-2 flex-wrap">
|
||||
<div className="italic text-sm text-muted-foreground">
|
||||
{posItem.type}
|
||||
</div>
|
||||
|
||||
{posItem.pronunciations.map((pron, i) => (
|
||||
<div
|
||||
key={`pron-${i}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<span className="uppercase text-xs font-serif text-muted-foreground">
|
||||
[{pron.region}]
|
||||
</span>
|
||||
<span className="text-sm font-code">
|
||||
/{pron.pronunciation}/
|
||||
</span>
|
||||
{pron.audio && (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onClick={() => {
|
||||
const audio = document.getElementById(
|
||||
`${posItem.type}-${pron.region}`
|
||||
) as HTMLAudioElement;
|
||||
if (audio) {
|
||||
audio.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Volume2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<audio
|
||||
className="hidden"
|
||||
id={`${posItem.type}-${pron.region}`}
|
||||
src={pron.audio}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="list-disc pl-4">
|
||||
{posItem.definitions.map((def, i) => (
|
||||
<li key={`pos-${i}`} className="">
|
||||
{def.definition}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
<CamdictLookupResult word={word} />
|
||||
|
||||
<Separator className="my-2" />
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("AiDictionary")}
|
||||
</div>
|
||||
{lookupResult ? (
|
||||
<div className="mb-4 select-text">
|
||||
<div className="mb-2">
|
||||
{lookupResult.meaning?.pos && (
|
||||
<span className="italic text-sm text-muted-foreground mr-2">
|
||||
{lookupResult.meaning.pos}
|
||||
</span>
|
||||
)}
|
||||
{lookupResult.meaning?.pronunciation && (
|
||||
<span className="text-sm font-code mr-2">
|
||||
/{lookupResult.meaning.pronunciation}/
|
||||
</span>
|
||||
)}
|
||||
{lookupResult.meaning?.lemma &&
|
||||
lookupResult.meaning.lemma !== lookupResult.meaning.word && (
|
||||
<span className="text-sm">({lookupResult.meaning.lemma})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-serif">{lookupResult.meaning.translation}</div>
|
||||
<div className="text-serif">{lookupResult.meaning.definition}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Button size="sm" disabled={lookingUp} onClick={lookup}>
|
||||
{lookingUp && <LoaderIcon className="animate-spin w-4 h-4 mr-2" />}
|
||||
<span>{t("AiTranslate")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AiLookupResult
|
||||
word={word}
|
||||
context={caption.text}
|
||||
sourceId={transcription.targetId}
|
||||
sourceType={transcription.targetType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -172,6 +172,8 @@ export const AssistantMessageComponent = (props: {
|
||||
{configuration.type === "gpt" && (
|
||||
<Markdown
|
||||
className="message-content select-text prose dark:prose-invert"
|
||||
data-source-type="Message"
|
||||
data-source-id={message.id}
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
try {
|
||||
|
||||
8
enjoy/src/renderer/components/misc/index.ts
Normal file
8
enjoy/src/renderer/components/misc/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./db-state";
|
||||
export * from "./layout";
|
||||
export * from "./loader-spin";
|
||||
export * from "./login-form";
|
||||
export * from "./no-records-found";
|
||||
export * from "./page-placeholder";
|
||||
export * from "./sidebar";
|
||||
export * from "./wavesurfer-player";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline";
|
||||
import { useState } from "react";
|
||||
import { WavesurferPlayer } from "@renderer/components/widgets";
|
||||
import { WavesurferPlayer } from "@/renderer/components/misc";
|
||||
|
||||
export const NoteSemgent = (props: {
|
||||
segment: SegmentType;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { STORAGE_WORKER_ENDPOINTS } from "@/constants";
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { t } from "i18next";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { WavesurferPlayer } from "../widgets";
|
||||
import { WavesurferPlayer } from "../misc";
|
||||
|
||||
export const PostAudio = (props: {
|
||||
audio: Partial<MediumType>;
|
||||
|
||||
@@ -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<HTMLDivElement>();
|
||||
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: {
|
||||
<article
|
||||
ref={ref}
|
||||
className="relative select-text prose dark:prose-invert prose-lg xl:prose-xl font-serif text-lg"
|
||||
data-source-type="Story"
|
||||
data-source-id={story.id}
|
||||
>
|
||||
<h2>
|
||||
{story.title.split(" ").map((word, i) => (
|
||||
@@ -176,39 +124,6 @@ export const StoryViewer = (props: {
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<Popover
|
||||
open={Boolean(selected?.word)}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor
|
||||
className="absolute w-0 h-0"
|
||||
style={{
|
||||
top: selected?.position?.top,
|
||||
left: selected?.position?.left,
|
||||
}}
|
||||
></PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-full max-w-md p-0"
|
||||
updatePositionStrategy="always"
|
||||
>
|
||||
{selected?.word && (
|
||||
<SelectionMenu
|
||||
word={selected?.word}
|
||||
context={selected?.context}
|
||||
sourceId={story.id}
|
||||
sourceType={"Story"}
|
||||
onLookup={(meaning) => {
|
||||
if (setMeanings) {
|
||||
setMeanings([...meanings, meaning]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -97,7 +97,7 @@ export const StoryVocabularySheet = (props: {
|
||||
onClick={() => setLookupInBatch(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("lookUpAll")}
|
||||
{t("lookupAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
@@ -125,7 +125,7 @@ export const StoryVocabularySheet = (props: {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("lookUp")}
|
||||
{t("lookup")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm mb-2">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<LookupType>();
|
||||
const [loading, setLoading] = useState<boolean>(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 (
|
||||
<div className="px-4 py-2">
|
||||
<MeaningCard meaning={result.meaning} lookup={result} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<div className="font-bold">{word}</div>
|
||||
<LoaderSpin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.status === "failed") {
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<div className="font-bold">{word}</div>
|
||||
<div className="h-full w-full px-4 py-4 flex justify-center items-center">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<XCircleIcon className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{t("pleaseTryLater")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<div className="font-bold mb-4">{word}</div>
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={processLookup} variant="default" size="sm">
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
285
enjoy/src/renderer/components/widgets/lookup-widget.tsx
Normal file
285
enjoy/src/renderer/components/widgets/lookup-widget.tsx
Normal file
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor
|
||||
className="absolute w-0 h-0"
|
||||
style={{
|
||||
top: selected?.position?.y,
|
||||
left: selected?.position?.x,
|
||||
}}
|
||||
></PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-full p-0 z-50"
|
||||
updatePositionStrategy="always"
|
||||
>
|
||||
{selected?.word && (
|
||||
<ScrollArea className="py-2 w-96 h-96 relative">
|
||||
<div className="px-4 pb-2 mb-2 font-bold text-lg sticky top-0 bg-background border-b">
|
||||
{selected?.word}
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<CamdictLookupResult word={selected?.word} />
|
||||
<Separator className="my-2" />
|
||||
<AiLookupResult
|
||||
word={selected?.word}
|
||||
context={selected?.context}
|
||||
sourceId={selected?.sourceId}
|
||||
sourceType={selected?.sourceType}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
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<boolean>(false);
|
||||
const [result, setResult] = useState<LookupType>();
|
||||
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 (
|
||||
<>
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("aiDictionary")}
|
||||
</div>
|
||||
{result ? (
|
||||
<div className="mb-4 select-text">
|
||||
<div className="mb-2 font-semibord font-serif">{word}</div>
|
||||
<div className="mb-2">
|
||||
{result.meaning?.pos && (
|
||||
<span className="italic text-sm text-muted-foreground mr-2">
|
||||
{result.meaning.pos}
|
||||
</span>
|
||||
)}
|
||||
{result.meaning?.pronunciation && (
|
||||
<span className="text-sm font-code mr-2">
|
||||
/{result.meaning.pronunciation.replaceAll("/", "")}/
|
||||
</span>
|
||||
)}
|
||||
{result.meaning?.lemma &&
|
||||
result.meaning.lemma !== result.meaning.word && (
|
||||
<span className="text-sm">({result.meaning.lemma})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-serif">{result.meaning.translation}</div>
|
||||
<div className="text-serif">{result.meaning.definition}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
asChild
|
||||
onClick={handleLookup}
|
||||
>
|
||||
<a>
|
||||
{lookingUp && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
<span>{t("aiLookup")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CamdictLookupResult = (props: { word: string }) => {
|
||||
const { word } = props;
|
||||
const { result } = useCamdict(word);
|
||||
|
||||
if (!word) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("cambridgeDictionary")}
|
||||
</div>
|
||||
{result ? (
|
||||
<div className="select-text">
|
||||
<div className="mb-2 font-semibord font-serif">{word}</div>
|
||||
{result.posItems.map((posItem, index) => (
|
||||
<div key={index} className="mb-4">
|
||||
<div className="flex items-center space-x-4 mb-2 flex-wrap">
|
||||
<div className="italic text-sm text-muted-foreground">
|
||||
{posItem.type}
|
||||
</div>
|
||||
|
||||
{posItem.pronunciations.map((pron, i) => (
|
||||
<div
|
||||
key={`pron-${i}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<span className="uppercase text-xs font-serif text-muted-foreground">
|
||||
[{pron.region}]
|
||||
</span>
|
||||
<span className="text-sm font-code">
|
||||
/{pron.pronunciation}/
|
||||
</span>
|
||||
{pron.audio && (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onClick={() => {
|
||||
const audio = document.getElementById(
|
||||
`${posItem.type}-${pron.region}`
|
||||
) as HTMLAudioElement;
|
||||
if (audio) {
|
||||
audio.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Volume2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<audio
|
||||
className="hidden"
|
||||
id={`${posItem.type}-${pron.region}`}
|
||||
src={pron.audio}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="list-disc pl-4">
|
||||
{posItem.definitions.map((def, i) => (
|
||||
<li key={`pos-${i}`} className="">
|
||||
{def.definition}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-serif text-muted-foreground py-2 text-center">
|
||||
- {t("noResultsFound")} -
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<LookupResult
|
||||
word={word}
|
||||
context={context}
|
||||
sourceId={sourceId}
|
||||
sourceType={sourceType}
|
||||
onResult={onLookup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Button onClick={() => setTranslating(true)} variant="ghost" size="icon">
|
||||
<LanguagesIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
enjoy/src/renderer/components/widgets/translate-widget.tsx
Normal file
165
enjoy/src/renderer/components/widgets/translate-widget.tsx
Normal file
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor
|
||||
className="absolute w-0 h-0"
|
||||
style={{
|
||||
top: selected?.position?.y,
|
||||
left: selected?.position?.x,
|
||||
}}
|
||||
></PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-full p-0 z-50 select-text"
|
||||
updatePositionStrategy="always"
|
||||
>
|
||||
<ScrollArea className="py-2 w-96 min-h-36 max-h-96 relative">
|
||||
<div className="px-4 pb-2 mb-2 sticky top-0 bg-background border-b">
|
||||
{selected?.text}
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<TranslateResult text={selected?.text} autoTranslate={true} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const TranslateResult = (props: {
|
||||
text: string;
|
||||
autoTranslate?: boolean;
|
||||
}) => {
|
||||
const { text, autoTranslate = false } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [translation, setTranslation] = useState<string>();
|
||||
const [translating, setTranslating] = useState<boolean>(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 ? (
|
||||
<div className="py-2 select-text">
|
||||
<div className="text-serif mb-4">{translation}</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={translating}
|
||||
onClick={handleTranslate}
|
||||
asChild
|
||||
>
|
||||
<a>
|
||||
{translating && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t("reTranslate")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center py-2">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
disabled={translating}
|
||||
onClick={handleTranslate}
|
||||
asChild
|
||||
>
|
||||
<a>
|
||||
{translating && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
<span>{t("aiTranslate")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
15
enjoy/src/types/enjoy-app.d.ts
vendored
15
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -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<void>;
|
||||
openPath: (path: string) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user