Skip to content

Commit

Permalink
✨ feat(article): translate button
Browse files Browse the repository at this point in the history
  • Loading branch information
summerscar committed Dec 20, 2024
1 parent f08920e commit 875457f
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 24 deletions.
7 changes: 7 additions & 0 deletions app/(home)/article/[slug]/_components/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SelectToSearch showAdd prompt="sentence">
<RenderMDTextServer
lang="ko"
text={resolvedContent}
className={clsx(notoKR.className, "pt-2")}
mdComponents={{
p: (props: { children: React.ReactNode }) => (
<TranslateParagraph {...props} />
),
}}
/>
</SelectToSearch>
);
Expand Down
52 changes: 52 additions & 0 deletions app/(home)/article/[slug]/_components/translate-paragraph.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex mb-4 group flex-wrap relative">
{isKoreanText ? (
<div className={clsx("select-none left-0 top-0.5 absolute")}>
{pending ? (
<span className="loading loading-spinner loading-xs size-4" />
) : (
<TranslateIcon
onClick={translate}
className={clsx(
"border rounded border-base-content group-hover:block mobile:block hidden size-4 cursor-pointer translate-y-[3px]",
pending && "animate-pulse pointer-events-none",
data && "!hidden",
)}
/>
)}
</div>
) : null}

<p className={clsx("!mb-0 w-full", isKoreanText && "pl-5")} {...props}>
{children}
</p>
{data && (
<p className="!mb-0 pl-5 !mt-1 w-full shrink-0 text-base-content/60">
{data.translatedText}
</p>
)}
</div>
);
};
export { TranslateParagraph };
2 changes: 1 addition & 1 deletion app/(home)/article/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const SlugPage = async ({
}

return (
<div className={clsx("container px-4 py-8 max-w-[1024px] mx-auto")}>
<div className={clsx("container px-4 sm:px-8 py-8 max-w-[1024px] mx-auto")}>
<div className="flex flex-col md:flex-row gap-8 mb-8">
{article.poster && (
<div className="w-full md:w-1/3">
Expand Down
12 changes: 8 additions & 4 deletions app/components/float-buttons-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,18 @@ 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 {
if (bottom + window.scrollY > document.body.clientHeight) {
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(() => {
Expand Down Expand Up @@ -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 && (
<FloatButton onClick={onPapagoTranslate} icon="translate" />
Expand Down Expand Up @@ -250,7 +254,7 @@ const AIPanel = ({
className="z-[5] left-0 right-0 absolute flex justify-center pointer-events-none"
>
<div
className={`flex backdrop-blur-md rounded-lg w-4/5 sm:w-[600px] min-h-40 max-h-96 sm:max-h-[65vh] justify-center items-stretch text-wrap text-base-content/80 border border-base-content/10 bg-white/10 shadow pointer-events-auto overflow-auto ${showAbove ? "mb-2" : "mt-2"}`}
className={`flex backdrop-blur-xl rounded-lg w-4/5 sm:w-[600px] min-h-40 max-h-96 sm:max-h-[65vh] justify-center items-stretch text-wrap text-base-content/80 border border-base-content/10 bg-white/10 shadow pointer-events-auto overflow-auto ${showAbove ? "mb-2" : "mt-2"}`}
>
<ErrorBoundary errorComponent={ErrorFallback}>
<SuggestionPanel promise={promise} />
Expand Down Expand Up @@ -288,7 +292,7 @@ const PapagoPanel = ({
}}
>
<div
className={`flex backdrop-blur-md rounded-lg w-4/5 sm:w-[400px] min-h-40 max-h-96 sm:max-h-[65vh] justify-center items-stretch text-wrap text-base-content/80 border border-base-content/10 bg-white/10 shadow pointer-events-auto overflow-auto ${showAbove ? "mb-2" : "mt-2"}`}
className={`flex backdrop-blur-xl rounded-lg w-4/5 sm:w-[400px] min-h-40 max-h-96 sm:max-h-[65vh] justify-center items-stretch text-wrap text-base-content/80 border border-base-content/10 bg-white/10 shadow pointer-events-auto overflow-auto ${showAbove ? "mb-2" : "mt-2"}`}
>
<ErrorBoundary errorComponent={ErrorFallback}>
<PapagoResult promise={promise} onSearch={onSearch} />
Expand Down
4 changes: 3 additions & 1 deletion app/components/papago-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ const PapagoResultRender = ({
{data.translatedText}
<img
onClick={onSearch}
/** 防止 useSelectToSearch 触发 */
onMouseUpCapture={(e) => 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"
/>
</div>
Expand Down
9 changes: 7 additions & 2 deletions app/components/render-md-server.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={clsx("markdown-body", className)} {...props}>
Expand Down
8 changes: 1 addition & 7 deletions app/hooks/use-hover-to-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 15 additions & 7 deletions app/hooks/use-select-to-search.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,19 +19,16 @@ const useSelectToSearch = ({
showTranslate = true,
showAI = true,
showAdd = false,
clickAway = true,
prompt,
}: Config = {}) => {
const containerRef = useRef<HTMLElement>(null);
const [selectedText, setSelectedText] = useState<string>("");
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();

Expand All @@ -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
Expand Down Expand Up @@ -81,6 +88,7 @@ type Config = {
showTranslate?: boolean;
showAI?: boolean;
showAdd?: boolean;
clickAway?: boolean;
prompt?:
| ComponentProps<typeof FloatButtonsPanel>["prompt"]
| "sentence"
Expand Down
11 changes: 9 additions & 2 deletions app/hooks/use-server-action-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,22 @@ const useServerActionState = <T extends (...args: any[]) => Promise<any>>(
action: T,
) => {
const [pending, setIsPending] = useState(false);
const [result, setResult] = useState<Awaited<ReturnType<T>> | 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<T>;
return [pending, serverAction] as const;
return [pending, serverAction, result] as const;
};

export { useServerActionState };
23 changes: 23 additions & 0 deletions app/utils/is-korean.ts
Original file line number Diff line number Diff line change
@@ -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 };

0 comments on commit 875457f

Please sign in to comment.