管理后台可关全站动画,并细调代码折叠、平滑滚动、装饰入场;统一 lib/motion,尊重系统减少动态效果。 Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
"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 { allowMotion } from "@/lib/motion";
|
||
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 !allowMotion("codeFold");
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|