feat(deeplink): jump from banner/rank list to the exact post in All Materials

- FigmaBanner: route same-app linkUrl through SPA navigation so the stream's
  scroll-to-post runs without a full reload; defer pointer capture until a real
  drag starts, fixing plain clicks being swallowed by setPointerCapture
- PopularRankList: rank rows navigate straight to /browse?sort=popular&post=<id>
- MessageStream: ?post= deep links jump directly to the target instead of
  resetting to the top and animating through the stream
- ScrollToTop: skip the top-reset for ?post= navigations so the target page
  handles its own alignment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TerryM
2026-05-30 17:52:50 +08:00
parent 0733ea8b18
commit 41299b5b65
4 changed files with 73 additions and 11 deletions

View File

@@ -3,8 +3,10 @@ import {
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate } from "react-router-dom";
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { langQuery, useI18n, type Lang } from "../i18n";
@@ -41,6 +43,24 @@ function bannerLangParam(lang: Lang): string {
return langQuery(lang).toLowerCase();
}
/**
* If `linkUrl` points inside this app (a relative path, or an absolute URL on
* the same origin), return the router path so we can navigate client-side —
* e.g. `/browse?post=<id>` triggers the stream's scroll-to-post without a full
* reload. External URLs return null and fall back to a plain `<a href>`.
*/
function internalPath(linkUrl: string): string | null {
try {
if (linkUrl.startsWith("//")) return null;
if (linkUrl.startsWith("/")) return linkUrl;
const url = new URL(linkUrl, window.location.origin);
if (url.origin !== window.location.origin) return null;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
function toSlides(items: BannerApiItem[]): BannerSlide[] {
return [...items]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
@@ -59,8 +79,12 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] {
export function FigmaBanner() {
const { lang } = useI18n();
const navigate = useNavigate();
const [slides, setSlides] = useState<BannerSlide[]>([]);
const scrollerRef = useRef<HTMLDivElement>(null);
// Set when a mouse drag just moved the carousel, so the synthetic `click`
// that follows pointerup doesn't navigate the slide's link.
const suppressClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0);
const [autoplayPaused, setAutoplayPaused] = useState(false);
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
@@ -177,7 +201,9 @@ export function FigmaBanner() {
startScrollLeft: scroller.scrollLeft,
moved: false,
};
scroller.setPointerCapture(event.pointerId);
// Don't capture the pointer yet: capturing on press makes the browser
// dispatch the following `click` to the scroller instead of the slide's
// <a>, swallowing the link. Capture only once a real drag begins (below).
pauseAutoplay();
};
@@ -189,6 +215,7 @@ export function FigmaBanner() {
const dx = event.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) > 4) {
drag.moved = true;
scroller.setPointerCapture(event.pointerId);
scroller.style.scrollSnapType = "none";
}
if (drag.moved) {
@@ -206,6 +233,11 @@ export function FigmaBanner() {
scroller.releasePointerCapture(event.pointerId);
}
if (drag.moved) {
// Swallow the click that the browser fires right after a drag-release.
suppressClickRef.current = true;
window.setTimeout(() => {
suppressClickRef.current = false;
}, 0);
const width = scroller.clientWidth || 1;
const nearest = Math.round(scroller.scrollLeft / width);
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
@@ -214,6 +246,27 @@ export function FigmaBanner() {
}
};
const handleSlideClick = (
event: ReactMouseEvent<HTMLAnchorElement>,
linkUrl: string,
) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.preventDefault();
return;
}
// Let modified clicks (new tab / window) and external links use default
// browser behavior; route same-app links through the SPA so the stream's
// `?post=<id>` scroll-to-post runs without a full page reload.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const path = internalPath(linkUrl);
if (!path) return;
event.preventDefault();
navigate(path);
};
if (slides.length === 0) return null;
const pagination = hasMultiple ? (
@@ -287,7 +340,12 @@ export function FigmaBanner() {
aria-label={`${index + 1} / ${slides.length}`}
>
{slide.linkUrl ? (
<a href={slide.linkUrl} className="block" rel="noreferrer">
<a
href={slide.linkUrl}
className="block"
rel="noreferrer"
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
>
{image}
</a>
) : (