diff --git a/app/(home)/learn/[level]/_components/markdown-wrapper.tsx b/app/(home)/learn/[level]/_components/markdown-wrapper.tsx index 84c94ab..5d27c08 100644 --- a/app/(home)/learn/[level]/_components/markdown-wrapper.tsx +++ b/app/(home)/learn/[level]/_components/markdown-wrapper.tsx @@ -1,7 +1,7 @@ "use client"; -import { useSelectToSearch } from "@/hooks/use-select-to-search"; +import { SelectToSearch } from "@/hooks/use-select-to-search"; import { usePathname } from "next/navigation"; -import { type ReactNode, type RefObject, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import type { TocItem } from "remark-flexible-toc"; const MDContentWrapper = ({ @@ -9,23 +9,15 @@ const MDContentWrapper = ({ lastModified, bottomNav, }: { children: ReactNode; lastModified?: string; bottomNav?: ReactNode }) => { - const [containerRef, panel] = useSelectToSearch({ - showAI: false, - }); - return (
-
} - > + {children} -
+ {bottomNav} - {panel}
); }; diff --git a/app/actions/papago-translate-action.ts b/app/actions/papago-translate-action.ts new file mode 100644 index 0000000..449f18f --- /dev/null +++ b/app/actions/papago-translate-action.ts @@ -0,0 +1,190 @@ +"use server"; +import Base64 from "crypto-js/enc-base64"; +import HmacMD5 from "crypto-js/hmac-md5"; + +const url = "https://papago.naver.com/apis/n2mt/translate"; + +export const papagoTranslateAction = async ( + text: string, + locale = "zh-CN", +): Promise => { + const deviceId = uuid(); + const body = { + deviceId, + locale, + dict: true, + dictDisplay: 30, + honorific: false, + instant: false, + paging: false, + source: "ko", + target: locale, + text, + usageAgreed: false, + }; + const headers = { + Accept: "application/json", + "Accept-Language": "zh-CN", + "device-type": "pc", + "x-apigw-partnerid": "papago", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", + Referer: "https://papago.naver.com/", + Origin: "https://papago.naver.com", + ...authorization(url, deviceId), + }; + + const response = await fetch(url, { + method: "POST", + headers: headers, + // @ts-ignore + body: new URLSearchParams(body), + mode: "no-cors", + }); + if (response.status !== 200) { + throw new Error((await response.text()) || response.statusText); + } + return await response.json(); +}; + +function uuid() { + let e = new Date().getTime(); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (a) => { + const t = ((e + 16 * Math.random()) % 16) | 0; + // biome-ignore lint/style/noCommaOperator: + return (e = Math.floor(e / 16)), ("x" === a ? t : (3 & t) | 8).toString(16); + }); +} + +function authorization(url: string, uuid: string) { + const t = uuid; + const n = new Date().getTime(); + return { + Authorization: `PPG ${t}:${HmacMD5( + `${t}\n${url.split("?")[0]}\n${n}`, + "v1.8.7_0c4b33bdb0", + ).toString(Base64)}`, + Timestamp: n.toString(), + }; +} + +interface Meaning { + meaning: string; + examples: string[]; + originalMeaning: string; +} + +interface Po { + type: string; + meanings: Meaning[]; +} + +interface Item { + entry: string; + subEntry?: string | null; + matchType: string; + hanjaEntry: string | null; + phoneticSigns: PhoneticSign[]; + pos: Po[]; + source: string; + url: string; + mUrl: string; + expDicTypeForm: string; + locale: string; + gdid: string; + expEntrySuperscript: string; +} + +interface Dict { + items?: Item[]; + examples: string[]; + isWordType: boolean; +} + +interface PhoneticSign { + type: null; + sign: string; +} + +interface Meaning { + meaning: string; + examples: string[]; + originalMeaning: string; +} + +interface Po { + type: string; + meanings: Meaning[]; +} + +interface Item { + entry: string; + subEntry?: string | null; + matchType: string; + hanjaEntry: string | null; + phoneticSigns: PhoneticSign[]; + pos: Po[]; + source: string; + url: string; + mUrl: string; + expDicTypeForm: string; + locale: string; + gdid: string; + expEntrySuperscript: string; +} + +interface TarDict { + items: Item[]; + examples: string[]; + isWordType: boolean; +} + +interface TlitResult { + token: string; + phoneme: string; +} + +interface Message { + tlitResult: TlitResult[]; +} + +interface TlitSrc { + message: Message; +} + +interface TlitResult { + token: string; + phoneme: string; +} + +interface Message { + tlitResult: TlitResult[]; +} + +interface Tlit { + message: Message; +} + +interface Nbest { + lang: string; + prob: number; +} + +interface LangDetection { + nbests: Nbest[]; +} + +export interface TranslateResult { + dict: Dict; + tarDict: TarDict; + delay: number; + delaySmt: number; + srcLangType: string; + tarLangType: string; + translatedText: string; + engineType: string; + tlitSrc: TlitSrc; + tlit: Tlit; + langDetection: LangDetection; +} diff --git a/app/assets/svg/translate.svg b/app/assets/svg/translate.svg new file mode 100644 index 0000000..e7b60c6 --- /dev/null +++ b/app/assets/svg/translate.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/select-search-button.tsx b/app/components/float-button.tsx similarity index 82% rename from app/components/select-search-button.tsx rename to app/components/float-button.tsx index c803fec..a4fd787 100644 --- a/app/components/select-search-button.tsx +++ b/app/components/float-button.tsx @@ -3,22 +3,24 @@ import CopyIcon from "@/assets/svg/copy.svg"; import AddIcon from "@/assets/svg/folder-plus.svg"; import SearchIcon from "@/assets/svg/search.svg"; import SparklesIcon from "@/assets/svg/sparkles.svg"; +import TranslateIcon from "@/assets/svg/translate.svg"; import type { CSSProperties } from "react"; interface SearchButtonProps { style?: CSSProperties; onClick: () => void; - icon: "search" | "copy" | "sparkles" | "add"; + icon: "search" | "copy" | "sparkles" | "add" | "translate"; } const IconMap = { search: SearchIcon, + translate: TranslateIcon, copy: CopyIcon, sparkles: SparklesIcon, add: AddIcon, }; -export const SearchButton = ({ +export const FloatButton = ({ style, onClick, icon = "search", diff --git a/app/components/float-buttons-panel.tsx b/app/components/float-buttons-panel.tsx index f329666..583d999 100644 --- a/app/components/float-buttons-panel.tsx +++ b/app/components/float-buttons-panel.tsx @@ -1,7 +1,12 @@ -"use client"; import { generateWordSuggestionAction } from "@/actions/generate-word-action"; +import { + type TranslateResult, + papagoTranslateAction, +} from "@/actions/papago-translate-action"; import { addWordsToUserDictAction } from "@/actions/user-dict-action"; -import { SearchButton } from "@/components/select-search-button"; +import { FloatButton } from "@/components/float-button"; +import { callModal } from "@/components/modal"; +import { PapagoResult } from "@/components/papago-render"; import { ErrorFallback } from "@/components/suspend-error-fallback"; import { useUserDictList } from "@/hooks/use-dict-list"; import { @@ -19,7 +24,6 @@ import { ErrorBoundary } from "next/dist/client/components/error-boundary"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { callModal } from "./modal"; const SuggestionPanel = dynamic( () => @@ -35,7 +39,7 @@ const SuggestionPanel = dynamic( interface FloatButtonsPanelProps { getRect: () => DOMRect; selectedText: string; - showSearch?: boolean; + showTranslate?: boolean; showCopy?: boolean; showAI?: boolean; showAdd?: boolean; @@ -43,18 +47,18 @@ interface FloatButtonsPanelProps { onClose?: () => void; prompt?: (word: string, locale: SITES_LANGUAGE) => string; ref?: React.RefObject; - onAIPanel?: () => void; + onNewPanel?: () => void; } export function FloatButtonsPanel({ getRect, selectedText, - showSearch = true, + showTranslate = true, showCopy = true, showAI = true, showAdd = false, onClose, - onAIPanel, + onNewPanel, position = "bottom", prompt, ref, @@ -64,13 +68,15 @@ export function FloatButtonsPanel({ const observerRef = useRef(null); const { isLogin } = useUser(); const dictList = useUserDictList({ filterFav: true }); - const [showAIPanel, setShowAI] = useState(false); + const [showAIPanel, setShowAIPanel] = useState(false); + const [showPapagoPanel, setShowPapagoPanel] = useState(false); + const showNewPanel = showAIPanel || showPapagoPanel; const memoedGetRect = useMemoizedFn(getRect); // biome-ignore lint/correctness/useExhaustiveDependencies: const rect = useMemo(() => { return memoedGetRect(); - }, [memoedGetRect, showAIPanel, selectedText]); + }, [memoedGetRect, showNewPanel, selectedText]); const onAdd = useMemoizedFn(async () => { onClose?.(); @@ -104,6 +110,17 @@ export function FloatButtonsPanel({ } }); + const onPapagoSearch = () => { + if (selectedText) { + const papagoUrl = `https://papago.naver.com/?sk=ko&tk=${locale}&st=${encodeURIComponent(selectedText)}`; + window.open( + papagoUrl, + "PapagoSearch", + "width=400,height=600,left=150,top=150", + ); + } + }; + const onCopy = async () => { try { await navigator.clipboard.writeText(selectedText); @@ -114,51 +131,49 @@ export function FloatButtonsPanel({ } }; - const onPapagoSearch = () => { - if (selectedText) { - const papagoUrl = `https://papago.naver.com/?sk=ko&tk=${locale}&st=${encodeURIComponent(selectedText)}`; - window.open( - papagoUrl, - "PapagoSearch", - "width=400,height=600,left=150,top=150", - ); - } - onClose?.(); + const onPapagoTranslate = async () => { + if (!selectedText) return; + await timeOut(16); + setShowPapagoPanel(true); + onNewPanel?.(); }; const openAISuggestion = async () => { if (!prompt) return; await timeOut(16); - setShowAI(true); - onAIPanel?.(); + setShowAIPanel(true); + onNewPanel?.(); }; const showAbove = useMemo(() => { - if (!showAI) return false; + if (!showNewPanel) return false; const windowHeight = window.innerHeight; const spaceBelow = windowHeight - rect.bottom; const spaceAbove = rect.top; - const showAbove = spaceBelow < 300 && spaceAbove > spaceBelow; - return showAbove; - }, [showAI, rect]); + return spaceBelow < 300 && spaceAbove > spaceBelow; + }, [showNewPanel, rect]); - useEffect(() => { - if (showAIPanel) { - observerRef.current = new ResizeObserver((entries) => { - const el = entries[0].target as HTMLDivElement; - const { height } = el.getBoundingClientRect(); - if (showAbove) { - el.style.top = `${rect.top - height + window.scrollY}px`; - } - }); - return () => { - observerRef.current?.disconnect(); - }; + const onResize = useMemoizedFn((entries: ResizeObserverEntry[]) => { + const el = entries[0].target as HTMLDivElement; + const { height, bottom } = el.getBoundingClientRect(); + if (showAbove) { + el.style.top = `${rect.top - height + window.scrollY}px`; + } else { + if (bottom + window.scrollY > document.body.clientHeight) { + el.style.top = `${rect.top - height + window.scrollY}px`; + } } - }, [showAIPanel, showAbove, rect]); + }); - // 只需要在 showAIPanel 时,渲染一次 + useEffect(() => { + observerRef.current = new ResizeObserver(onResize); + return () => { + observerRef.current?.disconnect(); + }; + }, [onResize]); + + // 只需要在 showNewPanel 时,渲染一次 // biome-ignore lint/correctness/useExhaustiveDependencies: const panel = useMemo( () => ( @@ -173,6 +188,14 @@ export function FloatButtonsPanel({ rect={rect} showAbove={showAbove} /> + ) : showPapagoPanel ? ( + ) : (
- {showAdd && } - {showSearch && ( - + {showTranslate && ( + )} - {showCopy && } + {showCopy && } {showAI && prompt && ( - + )} + {showAdd && }
)} ), - [showAIPanel, selectedText], + [showNewPanel, selectedText], ); return createPortal(panel, document.body); @@ -218,15 +241,16 @@ const AIPanel = ({ ref={(el) => { if (el) { observerRef?.current?.observe(el); + return () => observerRef.current?.unobserve(el); } }} style={{ top: `${showAbove ? rect.top - 160 + window.scrollY : rect.bottom + window.scrollY}px`, }} - className="z-[1] left-0 right-0 absolute flex justify-center pointer-events-none" + className="z-[5] left-0 right-0 absolute flex justify-center pointer-events-none" >
@@ -235,3 +259,41 @@ const AIPanel = ({
); }; + +const PapagoPanel = ({ + showAbove, + rect, + promise, + onSearch, + observerRef, +}: { + observerRef: React.RefObject; + showAbove: boolean; + rect: DOMRect; + promise: Promise; + onSearch: () => void; +}) => { + return ( +
{ + if (el) { + observerRef?.current?.observe(el); + return () => observerRef.current?.unobserve(el); + } + }} + > +
+ + + +
+
+ ); +}; diff --git a/app/components/papago-render.tsx b/app/components/papago-render.tsx new file mode 100644 index 0000000..7865fc7 --- /dev/null +++ b/app/components/papago-render.tsx @@ -0,0 +1,103 @@ +import type { + TranslateResult, + papagoTranslateAction, +} from "@/actions/papago-translate-action"; +import { SelectToSearch } from "@/hooks/use-select-to-search"; +import { Suspense, use } from "react"; +import { Pronunciation } from "./pronunciation"; + +const PapagoResult = ({ + promise, + onSearch, +}: { promise: Promise; onSearch?: () => void }) => { + return ( + + } + > + + + ); +}; + +const PapagoPromise = ({ + promise, + onSearch, +}: { promise: Promise; onSearch?: () => void }) => { + const res = use(promise); + return ; +}; + +const PapagoResultRender = ({ + data, + onSearch, +}: { + data: Awaited>; + onSearch?: () => void; +}) => { + return ( + +
+ {data.translatedText} + Papago +
+
    + {data.dict.items?.map((item) => ( +
  • +
    + + dangerouslySetInnerHTML={{ __html: item.entry }} + /> + (.+)<\/b>/, "$1")} /> + {item.hanjaEntry && ( + + [{item.hanjaEntry}] + + )} +
    +
      + {item.pos.map((posItem, posIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
    • + {posItem.type} +
        + {posItem.meanings.map((meaning, meaningIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
      • + {meaningIndex + 1}.{" "} + + dangerouslySetInnerHTML={{ + __html: meaning.meaning, + }} + /> +
      • + ))} +
      +
    • + ))} +
    +
  • + ))} +
+
+ ); +}; + +export { PapagoResult }; diff --git a/app/components/select-to-suggestion.tsx b/app/components/select-to-suggestion.tsx index 247df3d..4c5a211 100644 --- a/app/components/select-to-suggestion.tsx +++ b/app/components/select-to-suggestion.tsx @@ -1,17 +1,13 @@ "use client"; -import { useSelectToSearch } from "@/hooks/use-select-to-search"; +import { SelectToSearch } from "@/hooks/use-select-to-search"; import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote"; import { serialize } from "next-mdx-remote/serialize"; -import { type RefObject, Suspense, use, useEffect, useState } from "react"; +import { Suspense, use, useEffect, useState } from "react"; const TypeEffectString = ({ promise }: { promise: Promise }) => { const res = use(promise); const [displayText, setDisplayText] = useState(""); const [mdContent, setMdContent] = useState(); - const [ref, selectPanel] = useSelectToSearch({ - showAI: false, - showAdd: true, - }); useEffect(() => { let index = 0; @@ -45,13 +41,13 @@ const TypeEffectString = ({ promise }: { promise: Promise }) => { }, [displayText]); return ( -
} + {mdContent && } - {selectPanel} -
+ ); }; diff --git a/app/hooks/use-hover-to-search.tsx b/app/hooks/use-hover-to-search.tsx index b3dcf7c..a8bec95 100644 --- a/app/hooks/use-hover-to-search.tsx +++ b/app/hooks/use-hover-to-search.tsx @@ -117,7 +117,7 @@ const useHoverToSearch = ( onClose={() => { closePanel(); }} - onAIPanel={() => { + onNewPanel={() => { cancelAutoCloseRef.current?.(); }} /> diff --git a/app/hooks/use-select-to-search.tsx b/app/hooks/use-select-to-search.tsx index d1392d8..57f4cfe 100644 --- a/app/hooks/use-select-to-search.tsx +++ b/app/hooks/use-select-to-search.tsx @@ -15,7 +15,7 @@ import { const useSelectToSearch = ({ showCopy = true, - showSearch = true, + showTranslate = true, showAI = true, showAdd = false, prompt, @@ -29,7 +29,7 @@ const useSelectToSearch = ({ text, ); - const { run: showSearchButton } = useDebounceFn( + const { run: showFloatButton } = useDebounceFn( () => { const selection = window.getSelection(); const selectedText = selection?.toString().trim(); @@ -46,7 +46,7 @@ const useSelectToSearch = ({ { wait: 150 }, ); - useEventListener("mouseup", showSearchButton, { + useEventListener("mouseup", showFloatButton, { target: containerRef, }); const promptFn = @@ -62,7 +62,7 @@ const useSelectToSearch = ({ window.getSelection()!.getRangeAt(0).getBoundingClientRect() } selectedText={selectedText} - showSearch={showSearch} + showTranslate={showTranslate} showCopy={showCopy} showAI={showAI} showAdd={showAdd} @@ -78,7 +78,7 @@ const useSelectToSearch = ({ type Config = { showCopy?: boolean; - showSearch?: boolean; + showTranslate?: boolean; showAI?: boolean; showAdd?: boolean; prompt?: @@ -89,11 +89,14 @@ type Config = { const SelectToSearch = ({ children, + className, ...config -}: PropsWithChildren[0]>) => { +}: PropsWithChildren[0]> & { + className?: string; +}) => { const [containerRef, panel] = useSelectToSearch({ ...config }); return ( -
}> +
} className={className}> {children} {panel}
diff --git a/package-lock.json b/package-lock.json index e6958a7..9f18707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "ahooks": "^3.8.4", "canvas-confetti": "^1.9.3", "clsx": "^2.1.1", + "crypto-js": "^4.2.0", "dotenv": "^16.4.5", "es-hangul": "^2.2.4", "feed": "^4.2.2", @@ -65,6 +66,7 @@ "@biomejs/biome": "^1.9.4", "@prisma/nextjs-monorepo-workaround-plugin": "^5.22.0", "@types/canvas-confetti": "^1.6.4", + "@types/crypto-js": "^4.2.2", "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "19.0.1", @@ -81,7 +83,7 @@ "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss": "^3.4.1", "tsx": "^4.19.0", - "typescript": "^5" + "typescript": "^5.7.2" } }, "node_modules/@alloc/quick-lru": { @@ -8446,6 +8448,13 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -10317,6 +10326,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -20240,10 +20255,11 @@ } }, "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "devOptional": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 89b4688..41c6e5d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "ahooks": "^3.8.4", "canvas-confetti": "^1.9.3", "clsx": "^2.1.1", + "crypto-js": "^4.2.0", "dotenv": "^16.4.5", "es-hangul": "^2.2.4", "feed": "^4.2.2", @@ -80,6 +81,7 @@ "@biomejs/biome": "^1.9.4", "@prisma/nextjs-monorepo-workaround-plugin": "^5.22.0", "@types/canvas-confetti": "^1.6.4", + "@types/crypto-js": "^4.2.2", "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "19.0.1", @@ -96,7 +98,7 @@ "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss": "^3.4.1", "tsx": "^4.19.0", - "typescript": "^5" + "typescript": "^5.7.2" }, "cspell": { "version": "0.2", diff --git a/public/img/papago.png b/public/img/papago.png new file mode 100644 index 0000000..771cf14 Binary files /dev/null and b/public/img/papago.png differ