From 875457fd57d2b04e957a1a3a39e40b17fc7dc8af Mon Sep 17 00:00:00 2001 From: summerscar Date: Fri, 20 Dec 2024 18:51:05 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(article):=20translate=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/[slug]/_components/text.tsx | 7 +++ .../_components/translate-paragraph.tsx | 52 +++++++++++++++++++ app/(home)/article/[slug]/page.tsx | 2 +- app/components/float-buttons-panel.tsx | 12 +++-- app/components/papago-render.tsx | 4 +- app/components/render-md-server.tsx | 9 +++- app/hooks/use-hover-to-search.tsx | 8 +-- app/hooks/use-select-to-search.tsx | 22 +++++--- app/hooks/use-server-action-state.ts | 11 +++- app/utils/is-korean.ts | 23 ++++++++ 10 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 app/(home)/article/[slug]/_components/translate-paragraph.tsx create mode 100644 app/utils/is-korean.ts diff --git a/app/(home)/article/[slug]/_components/text.tsx b/app/(home)/article/[slug]/_components/text.tsx index 086714e..2ce49e4 100644 --- a/app/(home)/article/[slug]/_components/text.tsx +++ b/app/(home)/article/[slug]/_components/text.tsx @@ -2,15 +2,22 @@ import { RenderMDTextServer } from "@/components/render-md-server"; import { SelectToSearch } from "@/hooks/use-select-to-search"; import { notoKR } from "@/utils/fonts"; import clsx from "clsx"; +import { TranslateParagraph } from "./translate-paragraph"; const Text = ({ content }: { content: string }) => { const resolvedContent = content.replace("frameborder", "frameBorder"); + return ( ( + + ), + }} /> ); diff --git a/app/(home)/article/[slug]/_components/translate-paragraph.tsx b/app/(home)/article/[slug]/_components/translate-paragraph.tsx new file mode 100644 index 0000000..d9f23a1 --- /dev/null +++ b/app/(home)/article/[slug]/_components/translate-paragraph.tsx @@ -0,0 +1,52 @@ +"use client"; +import { papagoTranslateAction } from "@/actions/papago-translate-action"; +import TranslateIcon from "@/assets/svg/translate.svg"; +import { useServerActionState } from "@/hooks/use-server-action-state"; +import { isKorean } from "@/utils/is-korean"; +import clsx from "clsx"; +import { useLocale } from "next-intl"; + +const TranslateParagraph = ({ + children, + ...props +}: { children: React.ReactNode }) => { + const locale = useLocale(); + const text = typeof children === "string" ? children : ""; + const isKoreanText = isKorean(text); + + const [pending, translate, data] = useServerActionState(async () => { + if (!text) return undefined; + return await papagoTranslateAction(text, locale); + }); + + return ( +
+ {isKoreanText ? ( +
+ {pending ? ( + + ) : ( +
+ ) : null} + +

+ {children} +

+ {data && ( +

+ {data.translatedText} +

+ )} +
+ ); +}; +export { TranslateParagraph }; diff --git a/app/(home)/article/[slug]/page.tsx b/app/(home)/article/[slug]/page.tsx index ffde99b..795cec2 100644 --- a/app/(home)/article/[slug]/page.tsx +++ b/app/(home)/article/[slug]/page.tsx @@ -91,7 +91,7 @@ const SlugPage = async ({ } return ( -
+
{article.poster && (
diff --git a/app/components/float-buttons-panel.tsx b/app/components/float-buttons-panel.tsx index 583d999..24905e0 100644 --- a/app/components/float-buttons-panel.tsx +++ b/app/components/float-buttons-panel.tsx @@ -156,7 +156,7 @@ export function FloatButtonsPanel({ const onResize = useMemoizedFn((entries: ResizeObserverEntry[]) => { const el = entries[0].target as HTMLDivElement; - const { height, bottom } = el.getBoundingClientRect(); + const { height, bottom, right, width } = el.getBoundingClientRect(); if (showAbove) { el.style.top = `${rect.top - height + window.scrollY}px`; } else { @@ -164,6 +164,10 @@ export function FloatButtonsPanel({ el.style.top = `${rect.top - height + window.scrollY}px`; } } + + if (right + window.scrollX > document.body.clientWidth) { + el.style.left = `${document.body.clientWidth - width - 50 + window.scrollX}px`; + } }); useEffect(() => { @@ -204,7 +208,7 @@ export function FloatButtonsPanel({ : { top: `${rect.bottom + window.scrollY}px` }), left: `${rect.right - rect.width / 2 + window.scrollX}px`, }} - className="z-[1] border border-base-content/10 bg-white/10 shadow backdrop-blur-md flex absolute rounded overflow-hidden -translate-x-1/4" + className="z-[5] border border-base-content/10 bg-white/10 shadow backdrop-blur-md flex absolute rounded overflow-hidden -translate-x-1/4" > {showTranslate && ( @@ -250,7 +254,7 @@ const AIPanel = ({ className="z-[5] left-0 right-0 absolute flex justify-center pointer-events-none" >
@@ -288,7 +292,7 @@ const PapagoPanel = ({ }} >
diff --git a/app/components/papago-render.tsx b/app/components/papago-render.tsx index 7865fc7..e9ac11b 100644 --- a/app/components/papago-render.tsx +++ b/app/components/papago-render.tsx @@ -50,8 +50,10 @@ const PapagoResultRender = ({ {data.translatedText} e.stopPropagation()} src="/img/papago.png" - className="w-6 h-6 inline-block cursor-pointer" + className="w-6 h-6 inline-block cursor-pointer select-none" alt="Papago" />
diff --git a/app/components/render-md-server.tsx b/app/components/render-md-server.tsx index 2eb8cb1..3b5417e 100644 --- a/app/components/render-md-server.tsx +++ b/app/components/render-md-server.tsx @@ -1,16 +1,21 @@ import { components } from "@/components/markdown-render"; import clsx from "clsx"; +import type { MDXComponents } from "mdx/types"; import { compileMDX } from "next-mdx-remote/rsc"; import type { ComponentProps } from "react"; const RenderMDTextServer = async ({ text, className, + mdComponents, ...props -}: { text: string } & ComponentProps<"div">) => { +}: { + text: string; + mdComponents?: MDXComponents; +} & ComponentProps<"div">) => { const { content } = await compileMDX({ source: text, - components: { ...components }, + components: { ...components, ...mdComponents }, }); return (
diff --git a/app/hooks/use-hover-to-search.tsx b/app/hooks/use-hover-to-search.tsx index a8bec95..bd381e1 100644 --- a/app/hooks/use-hover-to-search.tsx +++ b/app/hooks/use-hover-to-search.tsx @@ -96,13 +96,7 @@ const useHoverToSearch = ( if (isTouchRef.current && targetRef.current?.contains(e.target as Node)) { return; } - - let parent: Element | null = e.target as Element; - while ((parent = parent.parentElement)) { - if (parent.getAttribute("data-ignore-click-away") === "true") { - return; - } - } + if ((e.target as HTMLElement).closest("[data-ignore-click-away]")) return; closePanel(); }, buttonContainer); diff --git a/app/hooks/use-select-to-search.tsx b/app/hooks/use-select-to-search.tsx index 57f4cfe..c7117d6 100644 --- a/app/hooks/use-select-to-search.tsx +++ b/app/hooks/use-select-to-search.tsx @@ -1,10 +1,11 @@ "use client"; import { FloatButtonsPanel } from "@/components/float-buttons-panel"; +import { isKorean } from "@/utils/is-korean"; import { generateSentenceSuggestionPrompt, generateWordSuggestionPrompt, } from "@/utils/prompts"; -import { useDebounceFn, useEventListener } from "ahooks"; +import { useClickAway, useDebounceFn, useEventListener } from "ahooks"; import { type ComponentProps, type PropsWithChildren, @@ -18,19 +19,16 @@ const useSelectToSearch = ({ showTranslate = true, showAI = true, showAdd = false, + clickAway = true, prompt, }: Config = {}) => { const containerRef = useRef(null); const [selectedText, setSelectedText] = useState(""); const showPanel = !!selectedText; - const isKorean = (text: string) => - /[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\u3000-\u303F\uFF00-\uFFEF\s]/.test( - text, - ); - const { run: showFloatButton } = useDebounceFn( - () => { + (e: MouseEvent) => { + if (!containerRef.current?.contains(e.target as Node)) return; const selection = window.getSelection(); const selectedText = selection?.toString().trim(); @@ -49,6 +47,15 @@ const useSelectToSearch = ({ useEventListener("mouseup", showFloatButton, { target: containerRef, }); + + useClickAway((e) => { + if (!clickAway) return; + + if ((e.target as HTMLElement).closest("[data-ignore-click-away]")) return; + + setSelectedText(""); + }, containerRef); + const promptFn = prompt === "sentence" ? generateSentenceSuggestionPrompt @@ -81,6 +88,7 @@ type Config = { showTranslate?: boolean; showAI?: boolean; showAdd?: boolean; + clickAway?: boolean; prompt?: | ComponentProps["prompt"] | "sentence" diff --git a/app/hooks/use-server-action-state.ts b/app/hooks/use-server-action-state.ts index 36ed564..3b50f61 100644 --- a/app/hooks/use-server-action-state.ts +++ b/app/hooks/use-server-action-state.ts @@ -11,15 +11,22 @@ const useServerActionState = Promise>( action: T, ) => { const [pending, setIsPending] = useState(false); + const [result, setResult] = useState> | undefined>( + undefined, + ); + const serverAction = useMemoizedFn(async (...args) => { setIsPending(true); + setResult(undefined); try { - return await action(...args); + const res = await action(...args); + setResult(res); + return res; } finally { setIsPending(false); } }) as AsyncAction; - return [pending, serverAction] as const; + return [pending, serverAction, result] as const; }; export { useServerActionState }; diff --git a/app/utils/is-korean.ts b/app/utils/is-korean.ts new file mode 100644 index 0000000..90f34d2 --- /dev/null +++ b/app/utils/is-korean.ts @@ -0,0 +1,23 @@ +const isKorean = (text: string) => { + /** + \uAC00-\uD7AF:韩文音节块(完整的韩文字符) + 包含了大部分常用的韩文字符 + 例如:가, 나, 다, 라 等 + \u1100-\u11FF:韩文字母(谚文字母) + 包含韩文的基本字母(자음 辅音和 모음 元音) + 例如:ㄱ, ㄴ, ㄷ, ㅏ, ㅑ 等 + \u3130-\u318F:韩文兼容字母 + 另一种表示韩文字母的 Unicode 范围 + 提供了额外的字母表示方式 + \u3000-\u303F:标点符号和空格 + 包含了一些东亚标点符号 + \uFF00-\uFFEF:全角字符 + 包含全角的韩文字符和标点符号 + \s:匹配任何空白字符(空格、制表符、换行等) + */ + // const symbol = /\u3000-\u303F\uFF00-\uFFEF\s/.test(text); + const koreanChar = /[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/.test(text); + return koreanChar; +}; + +export { isKorean };