From d693142dd4f05f85c641a0401a04971eda96ec2d Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 28 Oct 2025 22:06:07 +0800 Subject: [PATCH] feat(web): enhance code blocks with copy button and fix link navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add custom code block renderer with language display and copy functionality. Links now open in new windows, and clicking image links no longer triggers both link navigation and image preview. - Add CodeBlock component with copy-to-clipboard button and language label - Configure all markdown links to open in new windows with target="_blank" - Fix image link behavior to prevent duplicate actions when clicked 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- web/src/components/MemoContent/CodeBlock.tsx | 55 ++++++++++++++++++++ web/src/components/MemoContent/index.tsx | 7 +++ web/src/components/MemoView.tsx | 7 +++ 3 files changed, 69 insertions(+) create mode 100644 web/src/components/MemoContent/CodeBlock.tsx diff --git a/web/src/components/MemoContent/CodeBlock.tsx b/web/src/components/MemoContent/CodeBlock.tsx new file mode 100644 index 000000000..e22272590 --- /dev/null +++ b/web/src/components/MemoContent/CodeBlock.tsx @@ -0,0 +1,55 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +interface PreProps { + children?: React.ReactNode; + className?: string; +} + +export const CodeBlock = ({ children, className, ...props }: PreProps) => { + const [copied, setCopied] = useState(false); + + // Extract the code element and its props + const codeElement = children as React.ReactElement; + const codeClassName = codeElement?.props?.className || ""; + const codeContent = String(codeElement?.props?.children || "").replace(/\n$/, ""); + + // Extract language from className (format: language-xxx) + const match = /language-(\w+)/.exec(codeClassName); + const language = match ? match[1] : ""; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(codeContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy code:", err); + } + }; + + return ( +
+      
+ {language} + +
+
+ {children} +
+
+ ); +}; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 508cb44a5..e1b510ce2 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -10,6 +10,7 @@ import { useTranslate } from "@/utils/i18n"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { isSuperUser } from "@/utils/user"; +import { CodeBlock } from "./CodeBlock"; import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; import { MemoContentContext } from "./MemoContentContext"; import { Tag } from "./Tag"; @@ -102,6 +103,12 @@ const MemoContent = observer((props: Props) => { // Conditionally render custom components based on AST node type input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode), span: createConditionalComponent(Tag, "span", isTagNode), + pre: CodeBlock, + a: ({ href, children, ...props }) => ( + + {children} + + ), }} > {content} diff --git a/web/src/components/MemoView.tsx b/web/src/components/MemoView.tsx index 79e5fc6fe..165863e66 100644 --- a/web/src/components/MemoView.tsx +++ b/web/src/components/MemoView.tsx @@ -84,6 +84,13 @@ const MemoView: React.FC = observer((props: Props) => { const targetEl = e.target as HTMLElement; if (targetEl.tagName === "IMG") { + // Check if the image is inside a link + const linkElement = targetEl.closest("a"); + if (linkElement) { + // If image is inside a link, only navigate to the link (don't show preview) + return; + } + const imgUrl = targetEl.getAttribute("src"); if (imgUrl) { setPreviewImage({ open: true, urls: [imgUrl], index: 0 });