diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index f60c040..c81bb6b 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -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 { postJSON } from "../../api"; import { useI18n } from "../../i18n"; @@ -79,23 +79,72 @@ export function MessageStream({ scope }: MessageStreamProps) { ? hash.slice("#post-".length) : ""; const targetPostId = queryTargetPostId || hashTargetPostId; + const [isAligningQueryTarget, setIsAligningQueryTarget] = useState( + Boolean(queryTargetPostId), + ); const handledTargetRef = useRef(""); const targetScrollTimersRef = useRef([]); + const targetScrollFrameRef = useRef(null); const clearTargetScrollTimers = () => { for (const timer of targetScrollTimersRef.current) { window.clearTimeout(timer); } targetScrollTimersRef.current = []; + if (targetScrollFrameRef.current !== null) { + window.cancelAnimationFrame(targetScrollFrameRef.current); + targetScrollFrameRef.current = null; + } }; useEffect(() => { handledTargetRef.current = ""; clearTargetScrollTimers(); - }, [targetPostId]); + setIsAligningQueryTarget(Boolean(queryTargetPostId)); + }, [queryTargetPostId, targetPostId]); 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(() => { if (!targetPostId || handledTargetRef.current === targetPostId) return; @@ -104,39 +153,79 @@ export function MessageStream({ scope }: MessageStreamProps) { handledTargetRef.current = targetPostId; clearTargetScrollTimers(); - const scrollToTarget = (behavior: ScrollBehavior = "auto") => { + const targetScrollTop = () => { const target = document.getElementById(`post-${targetPostId}`); - if (!target) return; + if (!target) return null; const filterBottom = filterBarRef.current?.getBoundingClientRect().bottom ?? 0; const targetTop = target.getBoundingClientRect().top + window.scrollY; - window.scrollTo({ - top: Math.max(0, targetTop - filterBottom - 12), - left: 0, - behavior, - }); + return Math.max(0, targetTop - filterBottom - 12); + }; + + 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( "(prefers-reduced-motion: reduce)", ).matches; - // Query deep-links (`?post=`) usually come from Home cards/list rows. - // Keep that navigation stable by jumping directly to the target instead - // of first resetting to the top and then animating through the stream. - // Legacy hash links can still use the visible smooth scroll. - window.requestAnimationFrame(() => - scrollToTarget( - queryTargetPostId || prefersReducedMotion ? "auto" : "smooth", - ), - ); + if (queryTargetPostId) { + // Query deep-links (`?post=`) usually come from Home cards/list + // rows. Keep the premium motion, but drive it ourselves so scrolling is + // bounded to the current target position and cannot visibly pass the + // target before snapping back. User-driven scroll is temporarily locked + // by the effect above; programmatic scroll remains allowed. + 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 - // 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), - ); + // 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( @@ -148,6 +237,7 @@ export function MessageStream({ scope }: MessageStreamProps) { // Not loaded yet — keep paging until it appears or the stream is exhausted. if (hasMore && !isLoading) loadMore(); + else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); }, [targetPostId, items, hasMore, isLoading, loadMore]); const updateParam = (key: string, value: string) => {