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;
|
border-radius: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: 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 {
|
.prose-content blockquote {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
@@ -1807,6 +1815,8 @@ a.j13-mention-token:hover {
|
|||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* 收起后 scrollIntoView(start) 时避开 sticky 顶栏 */
|
||||||
|
scroll-margin-top: 5.5rem;
|
||||||
}
|
}
|
||||||
.prose-content .md-code-toolbar {
|
.prose-content .md-code-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1881,10 +1891,10 @@ a.j13-mention-token:hover {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shiki 双主题:跟随 html.dark */
|
/* Shiki 双主题:跟随 html.dark;变量缺省时回退到正文色,避免整块「隐形」 */
|
||||||
.prose-content .shiki,
|
.prose-content .shiki,
|
||||||
.prose-content .shiki span {
|
.prose-content .shiki span {
|
||||||
color: var(--shiki-light);
|
color: var(--shiki-light, var(--ink));
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
.prose-content .shiki {
|
.prose-content .shiki {
|
||||||
@@ -1892,7 +1902,7 @@ a.j13-mention-token:hover {
|
|||||||
}
|
}
|
||||||
html.dark .prose-content .shiki,
|
html.dark .prose-content .shiki,
|
||||||
html.dark .prose-content .shiki span {
|
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-style: var(--shiki-dark-font-style) !important;
|
||||||
font-weight: var(--shiki-dark-font-weight) !important;
|
font-weight: var(--shiki-dark-font-weight) !important;
|
||||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
type ReactElement,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -19,21 +20,51 @@ type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
|
|||||||
children?: ReactNode;
|
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 {
|
function extractText(node: ReactNode): string {
|
||||||
if (node == null || typeof node === "boolean") return "";
|
if (node == null || typeof node === "boolean") return "";
|
||||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||||
if (Array.isArray(node)) return node.map(extractText).join("");
|
if (Array.isArray(node)) {
|
||||||
if (typeof node === "object" && "props" in node) {
|
return node
|
||||||
const el = node as { props?: { children?: ReactNode } };
|
.map((child, i) => {
|
||||||
return extractText(el.props?.children);
|
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 "";
|
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 {
|
function countLines(pre: HTMLElement | null, fallbackText: string): number {
|
||||||
if (pre) {
|
if (pre) {
|
||||||
const lineEls = pre.querySelectorAll(".line, [data-line]");
|
// pretty-code / shiki:一行一个 [data-line] 或 .line,挂在 code 直下
|
||||||
if (lineEls.length > 0) return lineEls.length;
|
const direct = pre.querySelectorAll(":scope > code > [data-line], :scope > code > .line");
|
||||||
|
if (direct.length > 0) return direct.length;
|
||||||
}
|
}
|
||||||
const text = fallbackText.replace(/\n$/, "");
|
const text = fallbackText.replace(/\n$/, "");
|
||||||
if (!text) return 0;
|
if (!text) return 0;
|
||||||
@@ -49,22 +80,39 @@ function readLanguage(
|
|||||||
if (fromData) return fromData;
|
if (fromData) return fromData;
|
||||||
const cls = [className, el?.className].filter(Boolean).join(" ");
|
const cls = [className, el?.className].filter(Boolean).join(" ");
|
||||||
const m = cls.match(/language-([\w+-]+)/);
|
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) {
|
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const plainText = useMemo(() => extractText(children), [children]);
|
const restMap = rest as Record<string, unknown>;
|
||||||
const dataLanguage =
|
const dataLanguage = readProp(restMap, "data-language");
|
||||||
typeof (rest as Record<string, unknown>)["data-language"] === "string"
|
const dataTheme = readProp(restMap, "data-theme");
|
||||||
? String((rest as Record<string, unknown>)["data-language"])
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
|
const plainText = useMemo(() => extractText(children), [children]);
|
||||||
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
|
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 [copied, setCopied] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -81,9 +129,9 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
setLanguage(readLanguage(pre, className, dataLanguage));
|
setLanguage(readLanguage(pre, className, dataLanguage));
|
||||||
}, [plainText, className, dataLanguage, clientHtml]);
|
}, [plainText, className, dataLanguage, clientHtml]);
|
||||||
|
|
||||||
// 服务端已着色时跳过;客户端预览动态补双主题
|
// 仅客户端预览(无 SSR 高亮)时动态补双主题;切勿覆盖服务端已渲染内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (alreadyShiki || !plainText.trim()) return;
|
if (serverHighlighted || !plainText.trim()) return;
|
||||||
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
|
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -105,7 +153,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [plainText, className, dataLanguage, alreadyShiki]);
|
}, [plainText, className, dataLanguage, serverHighlighted]);
|
||||||
|
|
||||||
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
|
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
|
||||||
const canCollapse = effectiveLines > COLLAPSE_LINE_THRESHOLD;
|
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(" ");
|
const preClass = ["md-code-pre", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md-code-block">
|
<div className="md-code-block" ref={rootRef}>
|
||||||
<div className="md-code-toolbar">
|
<div className="md-code-toolbar">
|
||||||
<span className="md-code-lang">{language || "text"}</span>
|
<span className="md-code-lang">{language || "text"}</span>
|
||||||
<button
|
<button
|
||||||
@@ -159,11 +219,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
|
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
|
||||||
</div>
|
</div>
|
||||||
{canCollapse && (
|
{canCollapse && (
|
||||||
<button
|
<button type="button" className="md-code-fold" onClick={toggleFold}>
|
||||||
type="button"
|
|
||||||
className="md-code-fold"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
>
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp size={14} />
|
<ChevronUp size={14} />
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export const prettyCodeOptions = {
|
|||||||
},
|
},
|
||||||
keepBackground: false,
|
keepBackground: false,
|
||||||
defaultLang: "plaintext",
|
defaultLang: "plaintext",
|
||||||
|
// 空行在 display:grid 下会塌缩成 0 高,补一个空格撑住行高
|
||||||
|
onVisitLine(element: { children: unknown[] }) {
|
||||||
|
if (element.children.length === 0) {
|
||||||
|
element.children = [{ type: "text", value: " " }];
|
||||||
|
}
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
|
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
|
||||||
|
|||||||
Reference in New Issue
Block a user