diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 58382fe..2cc7cc8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1723,6 +1723,14 @@ a.j13-mention-token:hover { border-radius: 0; font-size: inherit; color: inherit; + min-width: 100%; +} +/* rehype-pretty-code grid 行:保证每行有高度,避免折叠/展开后大片空白 */ +.prose-content pre code[style*="grid"] > [data-line], +.prose-content .md-code-shiki-host code > .line, +.prose-content .md-code-shiki-host code > [data-line] { + min-height: 1.65em; + line-height: 1.65; } .prose-content blockquote { border-left: 3px solid var(--accent); @@ -1807,6 +1815,8 @@ a.j13-mention-token:hover { border-radius: var(--r-sm); background: var(--panel-2); overflow: hidden; + /* 收起后 scrollIntoView(start) 时避开 sticky 顶栏 */ + scroll-margin-top: 5.5rem; } .prose-content .md-code-toolbar { display: flex; @@ -1881,10 +1891,10 @@ a.j13-mention-token:hover { background: var(--accent-soft); } -/* Shiki 双主题:跟随 html.dark */ +/* Shiki 双主题:跟随 html.dark;变量缺省时回退到正文色,避免整块「隐形」 */ .prose-content .shiki, .prose-content .shiki span { - color: var(--shiki-light); + color: var(--shiki-light, var(--ink)); background-color: transparent !important; } .prose-content .shiki { @@ -1892,7 +1902,7 @@ a.j13-mention-token:hover { } html.dark .prose-content .shiki, html.dark .prose-content .shiki span { - color: var(--shiki-dark) !important; + color: var(--shiki-dark, var(--ink)) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; text-decoration: var(--shiki-dark-text-decoration) !important; diff --git a/frontend/components/markdown/MdCodeBlock.tsx b/frontend/components/markdown/MdCodeBlock.tsx index 2f3d9f7..93429d3 100644 --- a/frontend/components/markdown/MdCodeBlock.tsx +++ b/frontend/components/markdown/MdCodeBlock.tsx @@ -7,6 +7,7 @@ import { useEffect, type HTMLAttributes, type ReactNode, + type ReactElement, } from "react"; import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react"; import { toast } from "@/lib/toast"; @@ -19,21 +20,51 @@ type MdCodeBlockProps = HTMLAttributes & { children?: ReactNode; }; +function isReactElement( + node: ReactNode +): node is ReactElement<{ children?: ReactNode; className?: string }> { + return !!node && typeof node === "object" && "props" in node; +} + +/** 按 data-line / 块级结构补换行,避免行间文本被拼成一行 */ function extractText(node: ReactNode): string { if (node == null || typeof node === "boolean") return ""; if (typeof node === "string" || typeof node === "number") return String(node); - if (Array.isArray(node)) return node.map(extractText).join(""); - if (typeof node === "object" && "props" in node) { - const el = node as { props?: { children?: ReactNode } }; - return extractText(el.props?.children); + if (Array.isArray(node)) { + return node + .map((child, i) => { + const text = extractText(child); + if ( + i < node.length - 1 && + isReactElement(child) && + hasLineMarker(child) + ) { + return text.endsWith("\n") ? text : `${text}\n`; + } + return text; + }) + .join(""); + } + if (isReactElement(node)) { + return extractText(node.props.children); } return ""; } +function hasLineMarker(node: ReactElement): boolean { + const props = node.props as Record; + return ( + props["data-line"] != null || + (typeof props.className === "string" && + /\bline\b/.test(props.className)) + ); +} + function countLines(pre: HTMLElement | null, fallbackText: string): number { if (pre) { - const lineEls = pre.querySelectorAll(".line, [data-line]"); - if (lineEls.length > 0) return lineEls.length; + // pretty-code / shiki:一行一个 [data-line] 或 .line,挂在 code 直下 + const direct = pre.querySelectorAll(":scope > code > [data-line], :scope > code > .line"); + if (direct.length > 0) return direct.length; } const text = fallbackText.replace(/\n$/, ""); if (!text) return 0; @@ -49,22 +80,39 @@ function readLanguage( if (fromData) return fromData; const cls = [className, el?.className].filter(Boolean).join(" "); const m = cls.match(/language-([\w+-]+)/); - return m?.[1] || ""; + if (m?.[1]) return m[1]; + // react-markdown:语言在子 code 上 + const childCode = el?.querySelector(":scope > code"); + const childCls = childCode?.className || ""; + const m2 = childCls.match(/language-([\w+-]+)/); + return m2?.[1] || ""; } -/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端可补 Shiki 双主题 */ +function readProp( + rest: Record, + key: string +): string | undefined { + const v = rest[key]; + return typeof v === "string" ? v : undefined; +} + +/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端仅在未 SSR 高亮时补色 */ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) { + const rootRef = useRef(null); const hostRef = useRef(null); - const plainText = useMemo(() => extractText(children), [children]); - const dataLanguage = - typeof (rest as Record)["data-language"] === "string" - ? String((rest as Record)["data-language"]) - : undefined; + const restMap = rest as Record; + const dataLanguage = readProp(restMap, "data-language"); + const dataTheme = readProp(restMap, "data-theme"); + const plainText = useMemo(() => extractText(children), [children]); const approxLines = useMemo(() => countLines(null, plainText), [plainText]); - const alreadyShiki = - typeof className === "string" && className.includes("shiki"); + + // rehype-pretty-code 会去掉 pre 上的 shiki class,但会保留 data-theme / data-language + const serverHighlighted = + (typeof className === "string" && className.includes("shiki")) || + !!dataTheme || + !!dataLanguage; const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); @@ -81,9 +129,9 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc setLanguage(readLanguage(pre, className, dataLanguage)); }, [plainText, className, dataLanguage, clientHtml]); - // 服务端已着色时跳过;客户端预览动态补双主题 + // 仅客户端预览(无 SSR 高亮)时动态补双主题;切勿覆盖服务端已渲染内容 useEffect(() => { - if (alreadyShiki || !plainText.trim()) return; + if (serverHighlighted || !plainText.trim()) return; const lang = readLanguage(null, className, dataLanguage) || "plaintext"; let cancelled = false; (async () => { @@ -105,7 +153,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc return () => { cancelled = true; }; - }, [plainText, className, dataLanguage, alreadyShiki]); + }, [plainText, className, dataLanguage, serverHighlighted]); const effectiveLines = lineCount > 0 ? lineCount : approxLines; const canCollapse = effectiveLines > COLLAPSE_LINE_THRESHOLD; @@ -125,10 +173,22 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc } }; + const toggleFold = () => { + if (expanded) { + setExpanded(false); + // 收起后滚到该代码块顶部(含工具栏),便于继续往下读 + requestAnimationFrame(() => { + rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + } else { + setExpanded(true); + } + }; + const preClass = ["md-code-pre", className].filter(Boolean).join(" "); return ( -
+
{language || "text"}
{canCollapse && ( -