fix(frontend): 代码块折叠加高度动画,头部在视口外时滚动跟随
收起/展开用 max-height 过渡;头部仍在视口内不改滚动,在视口外则钉住收起按钮随动画跟滚。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1881,6 +1881,12 @@ a.j13-mention-token:hover {
|
|||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(to bottom, transparent, var(--panel-2));
|
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 {
|
.prose-content .md-code-fold {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -13,11 +13,33 @@ import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { useCodeFoldConfig } from "./CodeFoldProvider";
|
import { useCodeFoldConfig } from "./CodeFoldProvider";
|
||||||
|
|
||||||
/** 折叠预览高度:0=无预览;否则上下 padding ≈1.7rem + 每行 ≈1.21em */
|
const FOLD_ANIM_MS = 360;
|
||||||
function collapsedMaxHeight(foldLines: number): string {
|
/** 与 .md-code-block scroll-margin-top / sticky 顶栏对齐 */
|
||||||
if (foldLines <= 0) return "0px";
|
const HEADER_PAD_PX = 88;
|
||||||
return `calc(1.7rem + ${foldLines} * 1.21em)`;
|
|
||||||
|
/** 折叠预览高度: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> & {
|
type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -131,6 +153,7 @@ function hasShikiTokens(node: ReactNode): boolean {
|
|||||||
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
|
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const foldBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const { enabled: autoFold, foldLines } = useCodeFoldConfig();
|
const { enabled: autoFold, foldLines } = useCodeFoldConfig();
|
||||||
|
|
||||||
const restMap = rest as Record<string, unknown>;
|
const restMap = rest as Record<string, unknown>;
|
||||||
@@ -150,6 +173,32 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
readLanguage(null, className, dataLanguage)
|
readLanguage(null, className, dataLanguage)
|
||||||
);
|
);
|
||||||
const [clientHtml, setClientHtml] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
@@ -159,6 +208,19 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
setFoldReady(true);
|
setFoldReady(true);
|
||||||
}, [plainText, className, dataLanguage, clientHtml]);
|
}, [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 失败回退)时,客户端补双主题
|
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serverHighlighted || !plainText.trim()) return;
|
if (serverHighlighted || !plainText.trim()) return;
|
||||||
@@ -197,29 +259,92 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleFold = () => {
|
const toggleFold = () => {
|
||||||
if (expanded) {
|
const host = hostRef.current;
|
||||||
const el = rootRef.current;
|
const root = rootRef.current;
|
||||||
// 仅当代码块顶部已滚出可视区(长代码展开后往下读)才回顶;
|
|
||||||
// 短代码全程在视口内时保持浏览位置不变
|
if (animTimerRef.current != null) {
|
||||||
let shouldScrollBack = false;
|
window.clearTimeout(animTimerRef.current);
|
||||||
if (el) {
|
animTimerRef.current = null;
|
||||||
const top = el.getBoundingClientRect().top;
|
|
||||||
const headerPad = 88; // 与 scroll-margin-top: 5.5rem 对齐
|
|
||||||
shouldScrollBack = top < headerPad;
|
|
||||||
}
|
}
|
||||||
setExpanded(false);
|
stopScrollFollow();
|
||||||
if (shouldScrollBack) {
|
|
||||||
|
if (!host || prefersReducedMotion()) {
|
||||||
|
setAnimMaxPx(null);
|
||||||
|
const nextExpanded = !expanded;
|
||||||
|
setExpanded(nextExpanded);
|
||||||
|
// 无动画时:头部在视口外则收起后直接对齐到块顶
|
||||||
|
if (expanded && root) {
|
||||||
|
const top = root.getBoundingClientRect().top;
|
||||||
|
if (top < HEADER_PAD_PX) {
|
||||||
requestAnimationFrame(() => {
|
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 {
|
} else {
|
||||||
|
// 展开:从预览高度拉到完整高度(不跟滚)
|
||||||
|
const from = collapsedPx ?? host.getBoundingClientRect().height;
|
||||||
|
setAnimMaxPx(from);
|
||||||
setExpanded(true);
|
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(" ");
|
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 (
|
return (
|
||||||
<div className="md-code-block" ref={rootRef}>
|
<div className="md-code-block" ref={rootRef}>
|
||||||
<div className="md-code-toolbar">
|
<div className="md-code-toolbar">
|
||||||
@@ -236,8 +361,8 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={hostRef}
|
ref={hostRef}
|
||||||
className={`md-code-body${collapsed ? " md-code-collapsed" : ""}`}
|
className={`md-code-body${collapsed || animMaxPx != null ? " md-code-collapsed" : ""}`}
|
||||||
style={collapsed ? { maxHeight: collapsedMaxHeight(foldLines) } : undefined}
|
style={bodyStyle}
|
||||||
>
|
>
|
||||||
{clientHtml ? (
|
{clientHtml ? (
|
||||||
<div
|
<div
|
||||||
@@ -249,10 +374,15 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
{children}
|
{children}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
|
{showFade && <div className="md-code-fade" aria-hidden="true" />}
|
||||||
</div>
|
</div>
|
||||||
{canCollapse && (
|
{canCollapse && (
|
||||||
<button type="button" className="md-code-fold" onClick={toggleFold}>
|
<button
|
||||||
|
ref={foldBtnRef}
|
||||||
|
type="button"
|
||||||
|
className="md-code-fold"
|
||||||
|
onClick={toggleFold}
|
||||||
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp size={14} />
|
<ChevronUp size={14} />
|
||||||
|
|||||||
Reference in New Issue
Block a user