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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user