fix(frontend): 修复 Markdown 代码块折叠空白,收起滚回块顶

避免客户端覆盖 SSR 高亮;空行撑高;收起后 scrollIntoView 到代码块头部。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:50:40 +08:00
parent 2056d6a3f9
commit be47b38370
3 changed files with 99 additions and 27 deletions

View File

@@ -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;

View File

@@ -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<HTMLPreElement> & {
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<string, unknown>;
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<string, unknown>,
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<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const plainText = useMemo(() => extractText(children), [children]);
const dataLanguage =
typeof (rest as Record<string, unknown>)["data-language"] === "string"
? String((rest as Record<string, unknown>)["data-language"])
: undefined;
const restMap = rest as Record<string, unknown>;
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 (
<div className="md-code-block">
<div className="md-code-block" ref={rootRef}>
<div className="md-code-toolbar">
<span className="md-code-lang">{language || "text"}</span>
<button
@@ -159,11 +219,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
</div>
{canCollapse && (
<button
type="button"
className="md-code-fold"
onClick={() => setExpanded((v) => !v)}
>
<button type="button" className="md-code-fold" onClick={toggleFold}>
{expanded ? (
<>
<ChevronUp size={14} />

View File

@@ -17,6 +17,12 @@ export const prettyCodeOptions = {
},
keepBackground: false,
defaultLang: "plaintext",
// 空行在 display:grid 下会塌缩成 0 高,补一个空格撑住行高
onVisitLine(element: { children: unknown[] }) {
if (element.children.length === 0) {
element.children = [{ type: "text", value: " " }];
}
},
} as const;
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */