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:
an-lee
2024-05-10 11:16:38 +08:00
committed by GitHub
parent 69a6f721ca
commit 0e8de4881c
30 changed files with 597 additions and 552 deletions

View File

@@ -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}",
}}`;

View File

@@ -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"
}

View File

@@ -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": "没有找到结果"
}

View File

@@ -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.

View File

@@ -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),

View File

@@ -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>

View File

@@ -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";

View File

@@ -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}

View File

@@ -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}
/>
</>
);
};

View File

@@ -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 {

View 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";

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>
</>

View File

@@ -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">

View File

@@ -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";

View File

@@ -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>
);
};

View 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>
)}
</>
);
};

View File

@@ -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>
);
};

View 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>
)}
</>
);
};

View File

@@ -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);

View File

@@ -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>;