fix(frontend): 代码块折叠加高度动画,头部在视口外时滚动跟随

收起/展开用 max-height 过渡;头部仍在视口内不改滚动,在视口外则钉住收起按钮随动画跟滚。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 06:49:01 +08:00
parent 809d8f1df7
commit c6df1b3b7e
2 changed files with 157 additions and 21 deletions

View File

@@ -1881,6 +1881,12 @@ a.j13-mention-token:hover {
height: 2.75rem;
pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--panel-2));
transition: opacity 0.28s ease;
}
@media (prefers-reduced-motion: reduce) {
.prose-content .md-code-body {
transition: none !important;
}
}
.prose-content .md-code-fold {
display: flex;

View File

@@ -13,11 +13,33 @@ import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/lib/toast";
import { useCodeFoldConfig } from "./CodeFoldProvider";
/** 折叠预览高度:0=无预览;否则上下 padding ≈1.7rem + 每行 ≈1.21em */
function collapsedMaxHeight(foldLines: number): string {
if (foldLines <= 0) return "0px";
return `calc(1.7rem + ${foldLines} * 1.21em)`;
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;
};
@@ -131,6 +153,7 @@ function hasShikiTokens(node: ReactNode): boolean {
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>;
@@ -150,6 +173,32 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
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;
@@ -159,6 +208,19 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
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;
@@ -197,29 +259,92 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
};
const toggleFold = () => {
if (expanded) {
const el = rootRef.current;
// 仅当代码块顶部已滚出可视区(长代码展开后往下读)才回顶;
// 短代码全程在视口内时保持浏览位置不变
let shouldScrollBack = false;
if (el) {
const top = el.getBoundingClientRect().top;
const headerPad = 88; // 与 scroll-margin-top: 5.5rem 对齐
shouldScrollBack = top < headerPad;
const host = hostRef.current;
const root = rootRef.current;
if (animTimerRef.current != null) {
window.clearTimeout(animTimerRef.current);
animTimerRef.current = null;
}
setExpanded(false);
if (shouldScrollBack) {
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(() => {
rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
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">
@@ -236,8 +361,8 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
</div>
<div
ref={hostRef}
className={`md-code-body${collapsed ? " md-code-collapsed" : ""}`}
style={collapsed ? { maxHeight: collapsedMaxHeight(foldLines) } : undefined}
className={`md-code-body${collapsed || animMaxPx != null ? " md-code-collapsed" : ""}`}
style={bodyStyle}
>
{clientHtml ? (
<div
@@ -249,10 +374,15 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
{children}
</pre>
)}
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
{showFade && <div className="md-code-fade" aria-hidden="true" />}
</div>
{canCollapse && (
<button type="button" className="md-code-fold" onClick={toggleFold}>
<button
ref={foldBtnRef}
type="button"
className="md-code-fold"
onClick={toggleFold}
>
{expanded ? (
<>
<ChevronUp size={14} />