diff --git a/web/src/components/common/markdown-codeblock.tsx b/web/src/components/common/markdown-codeblock.tsx index b0bba41..026e7fd 100644 --- a/web/src/components/common/markdown-codeblock.tsx +++ b/web/src/components/common/markdown-codeblock.tsx @@ -1,97 +1,95 @@ 'use client' +import { useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; +import copyToClipboard from '@/lib/clipboard'; function extractText(node: React.ReactNode): string { - if (typeof node === "string") return node; - if (Array.isArray(node)) return node.map(extractText).join(""); - if ( - React.isValidElement(node) && - node.props && - typeof node.props === "object" && - "children" in node.props - ) { - return extractText(node.props.children as React.ReactNode); - } - return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(extractText).join(""); + if ( + React.isValidElement(node) && + node.props && + typeof node.props === "object" && + "children" in node.props + ) { + return extractText(node.props.children as React.ReactNode); + } + return ""; } export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">) { - let className: string | undefined = undefined; - const child = props.children as React.ReactElement<{ className?: string; children?: React.ReactNode }> | undefined; - if ( - child && - typeof child === "object" && - "props" in child && - child.props.className - ) { - className = child.props.className as string | undefined; - } - let language = ""; - if (className) { - const match = className.match(/language-(\w+)/); - if (match) { - language = match[1]; - } - } - let codeContent = ""; - if ( - child && - typeof child === "object" && - "props" in child - ) { - codeContent = extractText(child.props.children); + const t = useTranslations('CodeBlock'); + let className: string | undefined = undefined; + const child = props.children as React.ReactElement<{ className?: string; children?: React.ReactNode }> | undefined; + if ( + child && + typeof child === "object" && + "props" in child && + child.props.className + ) { + className = child.props.className as string | undefined; + } + let language = ""; + if (className) { + const match = className.match(/language-(\w+)/); + if (match) { + language = match[1]; } + } + let codeContent = ""; + if ( + child && + typeof child === "object" && + "props" in child + ) { + codeContent = extractText(child.props.children); + } - function handleCopy(e: React.MouseEvent) { - if (typeof window !== "undefined" && window.navigator?.clipboard) { - window.navigator.clipboard.writeText(codeContent); - } else { - const textarea = document.createElement("textarea"); - textarea.value = codeContent; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - } - toast.success("已经复制", { - description: "代码已复制到剪贴板", - }); + async function handleCopy(e: React.MouseEvent) { + try { + const ok = await copyToClipboard(codeContent); + if (ok) toast.success(t("copy_success")); + else toast.error(t("copy_failed") || 'Copy failed'); + } catch (err) { + console.error('copy failed', err); + toast.error(t("copy_failed") || 'Copy failed'); } + } - return ( -
-
- - - - {language && ( - - {language} - - )} -
+
+ + + + {language && ( + + {language} + + )} +
- -
-
-
+        >
+          
         
- ); +
+
+    
+ ); } \ No newline at end of file diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 0000000..30b402a --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,65 @@ +/** + * copyToClipboard + * - 优先使用 navigator.clipboard.writeText(异步) + * - 如果不可用或失败,使用隐藏的 textarea + document.execCommand('copy') 回退 + * - 尝试在回退时恢复原始 selection + * + * 返回 Promise,表示是否成功复制 + */ +export async function copyToClipboard(text: string): Promise { + // 优先使用现代 Clipboard API + try { + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(text); + return true; + } + } catch (err) { + // 忽略并回退到老方法 + // console.warn('navigator.clipboard.writeText failed, falling back to execCommand', err); + } + + // 回退到 textarea + execCommand 方案(在许多旧浏览器上可用) + if (typeof document === 'undefined') return false; + + const textarea = document.createElement('textarea'); + textarea.value = text; + // 防止页面滚动,把元素放到不可见但可选中的位置 + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + + // 保存当前 selection(以便恢复) + const selection = document.getSelection(); + let originalRange: Range | null = null; + if (selection && selection.rangeCount > 0) { + originalRange = selection.getRangeAt(0); + } + + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + // 使用 any 绕开 TypeScript 中关于 execCommand 的弃用声明 + const successful = (document as any).execCommand('copy'); + // 清理并恢复 selection + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + if (originalRange) selection.addRange(originalRange); + } + return Boolean(successful); + } catch (err) { + // 清理并恢复 selection + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + if (originalRange) selection.addRange(originalRange); + } + return false; + } +} + +export default copyToClipboard; diff --git a/web/src/locales/zh-CN.json b/web/src/locales/zh-CN.json index be0e262..3dd7090 100644 --- a/web/src/locales/zh-CN.json +++ b/web/src/locales/zh-CN.json @@ -16,6 +16,11 @@ "error": "验证失败", "success": "恭喜呀,你是个人!" }, + "CodeBlock": { + "copy": "复制", + "copy_success": "已经复制到剪贴板", + "copy_failed": "复制失败,请手动复制" + }, "Comment": { "collapse_replies": "收起", "comment": "评论", @@ -56,7 +61,11 @@ }, "Login": { "captcha_error": "验证错误,请重试。", + "fetch_captcha_config_failed": "获取验证码失败,请稍后重试。", + "fetch_oidc_configs_failed": "获取第三方身份提供者配置失败。", "logging": "正在登录...", + "login_success": "登录成功!", + "login_failed": "登录失败", "welcome": "欢迎回来", "with_oidc": "使用第三方身份提供者", "or_continue_with_local_account": "或使用用户名和密码", @@ -69,7 +78,6 @@ "by_logging_in_you_agree_to_our": "登录即表示你同意我们的", "terms_of_service": "服务条款", "and": "和", - "privacy_policy": "隐私政策", - "login_failed": "登录失败,请检查你的凭据。" + "privacy_policy": "隐私政策" } } \ No newline at end of file