fix(frontend): 修复 Markdown 代码块折叠空白,收起滚回块顶
避免客户端覆盖 SSR 高亮;空行撑高;收起后 scrollIntoView 到代码块头部。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 树 */
|
||||
|
||||
Reference in New Issue
Block a user