diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 5412734..19896a4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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; diff --git a/frontend/components/markdown/MdCodeBlock.tsx b/frontend/components/markdown/MdCodeBlock.tsx index 1033da9..736c671 100644 --- a/frontend/components/markdown/MdCodeBlock.tsx +++ b/frontend/components/markdown/MdCodeBlock.tsx @@ -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(":scope pre code > [data-line], :scope pre code > .line") || + host.querySelector("[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 & { children?: ReactNode; }; @@ -131,6 +153,7 @@ function hasShikiTokens(node: ReactNode): boolean { export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) { const rootRef = useRef(null); const hostRef = useRef(null); + const foldBtnRef = useRef(null); const { enabled: autoFold, foldLines } = useCodeFoldConfig(); const restMap = rest as Record; @@ -150,6 +173,32 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc readLanguage(null, className, dataLanguage) ); const [clientHtml, setClientHtml] = useState(null); + /** 动画中的 max-height(px);null 表示用稳态样式 */ + const [animMaxPx, setAnimMaxPx] = useState(null); + /** 折叠预览高度缓存(px),与动画终点一致 */ + const [collapsedPx, setCollapsedPx] = useState(null); + const animTimerRef = useRef(null); + const followRafRef = useRef(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 = () => { + 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 el = rootRef.current; - // 仅当代码块顶部已滚出可视区(长代码展开后往下读)才回顶; - // 短代码全程在视口内时保持浏览位置不变 - let shouldScrollBack = false; - if (el) { - const top = el.getBoundingClientRect().top; - const headerPad = 88; // 与 scroll-margin-top: 5.5rem 对齐 - shouldScrollBack = top < headerPad; - } - setExpanded(false); - if (shouldScrollBack) { + // 收起:高度动画;若头部在视口外,滚动跟随钉住「收起」按钮 + 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(() => { - rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); + 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 (
@@ -236,8 +361,8 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
{clientHtml ? (
)} - {collapsed &&