fix(stream): smooth scroll to linked post cards

Route resource card clicks to /browse?post=<id> instead of relying on hash
anchors, then manually calculate the sticky filter offset when scrolling to
the target bubble. Start from the top and smooth-scroll to the card for a
clear transition, with delayed auto realignments after media above the target
settles.
This commit is contained in:
TerryM
2026-05-30 03:11:03 +08:00
parent 6798e90708
commit a8fd540ef5
2 changed files with 67 additions and 16 deletions

View File

@@ -34,6 +34,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const filterBarRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore); const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading); const isLoadingRef = useRef(isLoading);
useEffect(() => { useEffect(() => {
@@ -70,33 +71,77 @@ export function MessageStream({ scope }: MessageStreamProps) {
return () => io.disconnect(); return () => io.disconnect();
}, [loadMore]); }, [loadMore]);
// When arriving with a `#post-<id>` hash (e.g. from a recommended card), // When arriving with a `?post=<id>` query (or legacy `#post-<id>` hash),
// scroll to that bubble — loading more pages until it shows up — then give // scroll to that bubble — loading more pages until it shows up — then give
// it a brief highlight so the user can see where they landed. // it a brief highlight so the user can see where they landed.
const targetPostId = hash.startsWith("#post-") const queryTargetPostId = sp.get("post") || "";
const hashTargetPostId = hash.startsWith("#post-")
? hash.slice("#post-".length) ? hash.slice("#post-".length)
: ""; : "";
const targetPostId = queryTargetPostId || hashTargetPostId;
const handledTargetRef = useRef<string>(""); const handledTargetRef = useRef<string>("");
const targetScrollTimersRef = useRef<number[]>([]);
const clearTargetScrollTimers = () => {
for (const timer of targetScrollTimersRef.current) {
window.clearTimeout(timer);
}
targetScrollTimersRef.current = [];
};
useEffect(() => { useEffect(() => {
handledTargetRef.current = ""; handledTargetRef.current = "";
clearTargetScrollTimers();
}, [targetPostId]); }, [targetPostId]);
useEffect(() => clearTargetScrollTimers, []);
useEffect(() => { useEffect(() => {
if (!targetPostId || handledTargetRef.current === targetPostId) return; if (!targetPostId || handledTargetRef.current === targetPostId) return;
const el = document.getElementById(`post-${targetPostId}`); const el = document.getElementById(`post-${targetPostId}`);
if (el) { if (el) {
handledTargetRef.current = targetPostId; handledTargetRef.current = targetPostId;
const frame = window.requestAnimationFrame(() => { clearTargetScrollTimers();
el.scrollIntoView({ block: "start", behavior: "smooth" });
el.classList.add("ark-bubble-highlight"); const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
window.setTimeout( const target = document.getElementById(`post-${targetPostId}`);
() => el.classList.remove("ark-bubble-highlight"), if (!target) return;
2000, const filterBottom =
); filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
}); const targetTop = target.getBoundingClientRect().top + window.scrollY;
return () => window.cancelAnimationFrame(frame); window.scrollTo({
top: Math.max(0, targetTop - filterBottom - 12),
left: 0,
behavior,
});
};
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
// Show a deliberate "from top to target" transition when opening a card
// from Home. The later auto re-alignments are intentionally delayed so
// they don't interrupt the visible smooth scroll animation.
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
);
// Media above the target can finish loading after the first scroll and
// shift the target downward. Re-align after the smooth animation while
// stream image/video heights settle, so the final resting point is exact.
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
window.setTimeout(() => scrollToTarget("auto"), ms),
);
el.classList.add("ark-bubble-highlight");
window.setTimeout(
() => el.classList.remove("ark-bubble-highlight"),
2000,
);
return;
} }
// Not loaded yet — keep paging until it appears or the stream is exhausted. // Not loaded yet — keep paging until it appears or the stream is exhausted.
@@ -116,7 +161,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> <div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
{/* Filters stay pinned below the global header (which shows the page {/* Filters stay pinned below the global header (which shows the page
name) so users can switch filters while scrolling. */} name) so users can switch filters while scrolling. */}
<div className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]"> <div
ref={filterBarRef}
className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]"
>
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} /> <FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
</div> </div>

View File

@@ -19,9 +19,12 @@ export function PostRedirect() {
if (POST_STREAM_USES_MOCK) { if (POST_STREAM_USES_MOCK) {
const post = MOCK_POSTS.find((p) => p.id === id); const post = MOCK_POSTS.find((p) => p.id === id);
navigate(post ? `/browse#post-${post.id}` : "/browse", { navigate(
replace: true, post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse",
}); {
replace: true,
},
);
return; return;
} }
@@ -29,7 +32,7 @@ export function PostRedirect() {
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
) )
.then((post) => { .then((post) => {
navigate(`/browse#post-${post.id}`, { navigate(`/browse?post=${encodeURIComponent(post.id)}`, {
replace: true, replace: true,
}); });
}) })