fix: bound post deeplink scrolling
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { postJSON } from "../../api";
|
import { postJSON } from "../../api";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
@@ -79,23 +79,72 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
? hash.slice("#post-".length)
|
? hash.slice("#post-".length)
|
||||||
: "";
|
: "";
|
||||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||||
|
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
|
||||||
|
Boolean(queryTargetPostId),
|
||||||
|
);
|
||||||
const handledTargetRef = useRef<string>("");
|
const handledTargetRef = useRef<string>("");
|
||||||
const targetScrollTimersRef = useRef<number[]>([]);
|
const targetScrollTimersRef = useRef<number[]>([]);
|
||||||
|
const targetScrollFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const clearTargetScrollTimers = () => {
|
const clearTargetScrollTimers = () => {
|
||||||
for (const timer of targetScrollTimersRef.current) {
|
for (const timer of targetScrollTimersRef.current) {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
}
|
}
|
||||||
targetScrollTimersRef.current = [];
|
targetScrollTimersRef.current = [];
|
||||||
|
if (targetScrollFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(targetScrollFrameRef.current);
|
||||||
|
targetScrollFrameRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handledTargetRef.current = "";
|
handledTargetRef.current = "";
|
||||||
clearTargetScrollTimers();
|
clearTargetScrollTimers();
|
||||||
}, [targetPostId]);
|
setIsAligningQueryTarget(Boolean(queryTargetPostId));
|
||||||
|
}, [queryTargetPostId, targetPostId]);
|
||||||
|
|
||||||
useEffect(() => clearTargetScrollTimers, []);
|
useEffect(() => clearTargetScrollTimers, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAligningQueryTarget) return;
|
||||||
|
const preventScroll = (event: Event) => event.preventDefault();
|
||||||
|
const preventScrollKeys = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowUp",
|
||||||
|
"PageDown",
|
||||||
|
"PageUp",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
" ",
|
||||||
|
].includes(event.key)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const html = document.documentElement;
|
||||||
|
const previousOverscroll = html.style.overscrollBehavior;
|
||||||
|
html.style.overscrollBehavior = "none";
|
||||||
|
window.addEventListener("wheel", preventScroll, {
|
||||||
|
capture: true,
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
window.addEventListener("touchmove", preventScroll, {
|
||||||
|
capture: true,
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", preventScrollKeys, { capture: true });
|
||||||
|
return () => {
|
||||||
|
html.style.overscrollBehavior = previousOverscroll;
|
||||||
|
window.removeEventListener("wheel", preventScroll, { capture: true });
|
||||||
|
window.removeEventListener("touchmove", preventScroll, { capture: true });
|
||||||
|
window.removeEventListener("keydown", preventScrollKeys, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [isAligningQueryTarget]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
||||||
|
|
||||||
@@ -104,39 +153,79 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
handledTargetRef.current = targetPostId;
|
handledTargetRef.current = targetPostId;
|
||||||
clearTargetScrollTimers();
|
clearTargetScrollTimers();
|
||||||
|
|
||||||
const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
|
const targetScrollTop = () => {
|
||||||
const target = document.getElementById(`post-${targetPostId}`);
|
const target = document.getElementById(`post-${targetPostId}`);
|
||||||
if (!target) return;
|
if (!target) return null;
|
||||||
const filterBottom =
|
const filterBottom =
|
||||||
filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
|
filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
|
||||||
const targetTop = target.getBoundingClientRect().top + window.scrollY;
|
const targetTop = target.getBoundingClientRect().top + window.scrollY;
|
||||||
window.scrollTo({
|
return Math.max(0, targetTop - filterBottom - 12);
|
||||||
top: Math.max(0, targetTop - filterBottom - 12),
|
};
|
||||||
left: 0,
|
|
||||||
behavior,
|
const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
|
||||||
});
|
const top = targetScrollTop();
|
||||||
|
if (top === null) return;
|
||||||
|
window.scrollTo({ top, left: 0, behavior });
|
||||||
|
};
|
||||||
|
|
||||||
|
const boundedSmoothScrollToTarget = () => {
|
||||||
|
const firstTarget = targetScrollTop();
|
||||||
|
if (firstTarget === null) return;
|
||||||
|
const start = window.scrollY;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const duration = 520;
|
||||||
|
const direction = firstTarget >= start ? 1 : -1;
|
||||||
|
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3);
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const latestTarget = targetScrollTop();
|
||||||
|
if (latestTarget === null) return;
|
||||||
|
const progress = Math.min(1, (now - startedAt) / duration);
|
||||||
|
const ideal = start + (latestTarget - start) * easeOutCubic(progress);
|
||||||
|
const next =
|
||||||
|
direction >= 0
|
||||||
|
? Math.min(ideal, latestTarget)
|
||||||
|
: Math.max(ideal, latestTarget);
|
||||||
|
window.scrollTo({ top: next, left: 0, behavior: "auto" });
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToTarget("auto");
|
||||||
|
setIsAligningQueryTarget(false);
|
||||||
|
targetScrollFrameRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefersReducedMotion = window.matchMedia(
|
const prefersReducedMotion = window.matchMedia(
|
||||||
"(prefers-reduced-motion: reduce)",
|
"(prefers-reduced-motion: reduce)",
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
// Query deep-links (`?post=<id>`) usually come from Home cards/list rows.
|
if (queryTargetPostId) {
|
||||||
// Keep that navigation stable by jumping directly to the target instead
|
// Query deep-links (`?post=<id>`) usually come from Home cards/list
|
||||||
// of first resetting to the top and then animating through the stream.
|
// rows. Keep the premium motion, but drive it ourselves so scrolling is
|
||||||
// Legacy hash links can still use the visible smooth scroll.
|
// bounded to the current target position and cannot visibly pass the
|
||||||
window.requestAnimationFrame(() =>
|
// target before snapping back. User-driven scroll is temporarily locked
|
||||||
scrollToTarget(
|
// by the effect above; programmatic scroll remains allowed.
|
||||||
queryTargetPostId || prefersReducedMotion ? "auto" : "smooth",
|
targetScrollTimersRef.current = [80].map((ms) =>
|
||||||
),
|
window.setTimeout(boundedSmoothScrollToTarget, ms),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
window.requestAnimationFrame(() =>
|
||||||
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||||
|
);
|
||||||
|
|
||||||
// Media above the target can finish loading after the first scroll and
|
// Media above the target can finish loading after the first scroll and
|
||||||
// shift the target downward. Re-align after the smooth animation while
|
// shift the target downward. Re-align after the smooth animation while
|
||||||
// stream image/video heights settle, so the final resting point is exact.
|
// stream image/video heights settle, so the final resting point is exact.
|
||||||
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
|
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
|
||||||
window.setTimeout(() => scrollToTarget("auto"), ms),
|
window.setTimeout(() => scrollToTarget("auto"), ms),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
el.classList.add("ark-bubble-highlight");
|
el.classList.add("ark-bubble-highlight");
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
@@ -148,6 +237,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
// 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.
|
||||||
if (hasMore && !isLoading) loadMore();
|
if (hasMore && !isLoading) loadMore();
|
||||||
|
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
||||||
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
||||||
|
|
||||||
const updateParam = (key: string, value: string) => {
|
const updateParam = (key: string, value: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user