Files
jiang13-bbs/frontend/components/markdown/MdCodeBlock.tsx
freefire c6df1b3b7e fix(frontend): 代码块折叠加高度动画,头部在视口外时滚动跟随
收起/展开用 max-height 过渡;头部仍在视口内不改滚动,在视口外则钉住收起按钮随动画跟滚。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 06:49:01 +08:00

402 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
useMemo,
useRef,
useState,
useEffect,
type HTMLAttributes,
type ReactNode,
type ReactElement,
} from "react";
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/lib/toast";
import { useCodeFoldConfig } from "./CodeFoldProvider";
const FOLD_ANIM_MS = 360;
/** 与 .md-code-block scroll-margin-top / sticky 顶栏对齐 */
const HEADER_PAD_PX = 88;
/** 折叠预览高度:0=无预览;按实际行高测量,避免 calc 与动画像素不一致 */
function measureCollapsedPx(host: HTMLElement, foldLines: number): number {
if (foldLines <= 0) return 0;
const pre = host.querySelector("pre");
const line =
host.querySelector<HTMLElement>(":scope pre code > [data-line], :scope pre code > .line") ||
host.querySelector<HTMLElement>("[data-line], .line");
const lineH = line?.getBoundingClientRect().height || 20;
let padY = 0;
if (pre) {
const cs = getComputedStyle(pre);
padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
}
return Math.max(0, Math.round(padY + foldLines * lineH));
}
function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
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((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) {
// 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;
return text.split("\n").length;
}
function readLanguage(
el: HTMLElement | null,
className?: string,
dataLanguage?: string
): string {
const fromData = dataLanguage || el?.getAttribute("data-language") || "";
if (fromData) return fromData;
const cls = [className, el?.className].filter(Boolean).join(" ");
const m = cls.match(/language-([\w+-]+)/);
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] || "";
}
function readProp(
rest: Record<string, unknown>,
key: string
): string | undefined {
const v = rest[key];
return typeof v === "string" ? v : undefined;
}
/** 子树里是否已有 Shiki 双主题 token(--shiki-light),用于判断 SSR 是否真正着色成功 */
function hasShikiTokens(node: ReactNode): boolean {
if (node == null || typeof node === "boolean") return false;
if (Array.isArray(node)) return node.some(hasShikiTokens);
if (!isReactElement(node)) return false;
const props = node.props as Record<string, unknown>;
const style = props.style;
if (typeof style === "string" && style.includes("--shiki-light")) return true;
if (
style &&
typeof style === "object" &&
("--shiki-light" in (style as object) ||
Object.values(style as Record<string, unknown>).some(
(v) => typeof v === "string" && String(v).includes("#")
))
) {
// React 可能把 CSS 变量写成 style={{ ['--shiki-light']: '#xxx' }}
const rec = style as Record<string, unknown>;
if ("--shiki-light" in rec || "color" in rec) {
// 有 --shiki-light 即视为已着色;仅有 color 不够(可能是别的)
if ("--shiki-light" in rec) return true;
}
for (const k of Object.keys(rec)) {
if (k.includes("shiki")) return true;
}
}
return hasShikiTokens(props.children as ReactNode);
}
/** Markdown 代码块:语言标签、复制、超阈值行数默认折叠;客户端仅在未真正着色时补色 */
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const foldBtnRef = useRef<HTMLButtonElement>(null);
const { enabled: autoFold, foldLines } = useCodeFoldConfig();
const restMap = rest as Record<string, unknown>;
const dataLanguage = readProp(restMap, "data-language");
const plainText = useMemo(() => extractText(children), [children]);
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
// 以是否存在 --shiki-light token 为准;勿仅凭 data-language(失败回退 plaintext 时仍带该属性)
const serverHighlighted = useMemo(() => hasShikiTokens(children), [children]);
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
/** 挂载后再启用折叠,避免 SSR/CSR 行数统计不一致导致 hydration mismatch */
const [foldReady, setFoldReady] = useState(false);
const [lineCount, setLineCount] = useState(0);
const [language, setLanguage] = useState(() =>
readLanguage(null, className, dataLanguage)
);
const [clientHtml, setClientHtml] = useState<string | null>(null);
/** 动画中的 max-height(px);null 表示用稳态样式 */
const [animMaxPx, setAnimMaxPx] = useState<number | null>(null);
/** 折叠预览高度缓存(px),与动画终点一致 */
const [collapsedPx, setCollapsedPx] = useState<number | null>(null);
const animTimerRef = useRef<number | null>(null);
const followRafRef = useRef<number | null>(null);
const stopScrollFollow = () => {
if (followRafRef.current != null) {
cancelAnimationFrame(followRafRef.current);
followRafRef.current = null;
}
};
/** 收起时钉住「收起」按钮视口位置,滚动随高度收缩上移,头部从上方进入视野 */
const startScrollFollow = (pinTop: number, foldEl: HTMLElement) => {
stopScrollFollow();
const tick = () => {
const drift = foldEl.getBoundingClientRect().top - pinTop;
if (Math.abs(drift) > 0.5) {
window.scrollBy({ top: drift, left: 0, behavior: "auto" });
}
followRafRef.current = requestAnimationFrame(tick);
};
followRafRef.current = requestAnimationFrame(tick);
};
useEffect(() => {
const host = hostRef.current;
const pre = host?.querySelector("pre") ?? null;
setLineCount(countLines(pre, plainText));
setLanguage(readLanguage(pre, className, dataLanguage));
setFoldReady(true);
}, [plainText, className, dataLanguage, clientHtml]);
useEffect(() => {
const host = hostRef.current;
if (!foldReady || !host) return;
setCollapsedPx(measureCollapsedPx(host, foldLines));
}, [foldReady, foldLines, clientHtml, lineCount]);
useEffect(() => {
return () => {
if (animTimerRef.current != null) window.clearTimeout(animTimerRef.current);
stopScrollFollow();
};
}, []);
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
useEffect(() => {
if (serverHighlighted || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
let cancelled = false;
(async () => {
try {
const { siteCodeToHtml } = await import("./shikiHighlighter");
const html = await siteCodeToHtml(plainText.replace(/\n$/, ""), lang);
if (!cancelled) setClientHtml(html);
} catch {
// 未知语言 / 加载失败:保持纯文本
}
})();
return () => {
cancelled = true;
};
}, [plainText, className, dataLanguage, serverHighlighted]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = autoFold && foldReady && effectiveLines > foldLines;
const collapsed = canCollapse && !expanded;
const onCopy = async () => {
const pre = hostRef.current?.querySelector("pre");
const text =
pre?.innerText?.replace(/\n$/, "") || plainText.replace(/\n$/, "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
toast("复制失败,请手动选择文本");
}
};
const toggleFold = () => {
const host = hostRef.current;
const root = rootRef.current;
if (animTimerRef.current != null) {
window.clearTimeout(animTimerRef.current);
animTimerRef.current = null;
}
stopScrollFollow();
if (!host || prefersReducedMotion()) {
setAnimMaxPx(null);
const nextExpanded = !expanded;
setExpanded(nextExpanded);
// 无动画时:头部在视口外则收起后直接对齐到块顶
if (expanded && root) {
const top = root.getBoundingClientRect().top;
if (top < HEADER_PAD_PX) {
requestAnimationFrame(() => {
root.scrollIntoView({ block: "start", behavior: "auto" });
});
}
}
return;
}
if (expanded) {
// 收起:高度动画;若头部在视口外,滚动跟随钉住「收起」按钮
const from = host.scrollHeight;
const to = collapsedPx ?? measureCollapsedPx(host, foldLines);
const headerTop = root?.getBoundingClientRect().top ?? 0;
const headerOut = headerTop < HEADER_PAD_PX;
const foldEl = foldBtnRef.current;
const pinTop = foldEl?.getBoundingClientRect().top;
setAnimMaxPx(from);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setExpanded(false);
setAnimMaxPx(to);
if (headerOut && foldEl != null && pinTop != null) {
startScrollFollow(pinTop, foldEl);
}
animTimerRef.current = window.setTimeout(() => {
stopScrollFollow();
setAnimMaxPx(null);
animTimerRef.current = null;
}, FOLD_ANIM_MS + 40);
});
});
} else {
// 展开:从预览高度拉到完整高度(不跟滚)
const from = collapsedPx ?? host.getBoundingClientRect().height;
setAnimMaxPx(from);
setExpanded(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnimMaxPx(host.scrollHeight);
animTimerRef.current = window.setTimeout(() => {
setAnimMaxPx(null);
animTimerRef.current = null;
}, FOLD_ANIM_MS + 40);
});
});
}
};
const preClass = ["md-code-pre", "shiki", className].filter(Boolean).join(" ");
// 动画用像素 max-height;稳态折叠用缓存高度;不改动滚动位置
const bodyStyle = (() => {
if (animMaxPx != null) {
return {
maxHeight: `${animMaxPx}px`,
transition: `max-height ${FOLD_ANIM_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`,
} as const;
}
if (!collapsed) return undefined;
if (collapsedPx != null) return { maxHeight: `${collapsedPx}px` } as const;
if (foldLines <= 0) return { maxHeight: "0px" } as const;
return {
maxHeight: `calc(1.7rem + ${foldLines} * 1.21em)`,
} as const;
})();
const showFade = collapsed || (animMaxPx != null && !expanded);
return (
<div className="md-code-block" ref={rootRef}>
<div className="md-code-toolbar">
<span className="md-code-lang">{language || "text"}</span>
<button
type="button"
className="md-code-copy"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
<div
ref={hostRef}
className={`md-code-body${collapsed || animMaxPx != null ? " md-code-collapsed" : ""}`}
style={bodyStyle}
>
{clientHtml ? (
<div
className="md-code-shiki-host"
dangerouslySetInnerHTML={{ __html: clientHtml }}
/>
) : (
<pre className={preClass} {...rest}>
{children}
</pre>
)}
{showFade && <div className="md-code-fade" aria-hidden="true" />}
</div>
{canCollapse && (
<button
ref={foldBtnRef}
type="button"
className="md-code-fold"
onClick={toggleFold}
>
{expanded ? (
<>
<ChevronUp size={14} />
收起
</>
) : (
<>
<ChevronDown size={14} />
展开全部({effectiveLines} 行)
</>
)}
</button>
)}
</div>
);
}