gs4f2wcln: Fit long text-only posts to the slide and add Read more and more/less for long post text on mobile
This commit is contained in:
@@ -6,6 +6,7 @@ import { GrowButton, SweepButton } from "./Buttons";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { PlayIcon, PostMenu } from "./PostCard";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import { CaptionText, QuoteText } from "./SlideText";
|
||||
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||
import { Logo } from "./Logo";
|
||||
import { useTheme } from "@/lib/use-theme";
|
||||
@@ -41,7 +42,7 @@ export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_
|
||||
export function isTapTarget(target: EventTarget | null): boolean {
|
||||
const el = target as Element | null;
|
||||
if (!el || typeof el.closest !== "function") return true;
|
||||
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem']");
|
||||
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem'], [data-expanded-text]");
|
||||
}
|
||||
|
||||
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
|
||||
@@ -185,13 +186,36 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
|
||||
function PostSlide({
|
||||
post,
|
||||
index,
|
||||
current,
|
||||
p,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
post: CardPost;
|
||||
index: number;
|
||||
current: boolean;
|
||||
p: FeedViewProps;
|
||||
onExpandedChange?: (index: number, open: boolean) => void;
|
||||
}) {
|
||||
const items = post.media.filter((m) => m.kind === "video" || m.src);
|
||||
const media = items[0] ?? null;
|
||||
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
||||
// While a video plays, the author and text step aside so the native controls stay clear.
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
|
||||
// Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!current) setTextOpen(false);
|
||||
}, [current]);
|
||||
useEffect(() => {
|
||||
onExpandedChange?.(index, textOpen);
|
||||
}, [index, textOpen, onExpandedChange]);
|
||||
useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
|
||||
const cls =
|
||||
`slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
|
||||
`${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`;
|
||||
const lastTap = useRef<number | null>(null);
|
||||
const [burst, setBurst] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -227,9 +251,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
||||
<div className="slide-scrim" aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
<div className="slide-quote-w">
|
||||
<p className="slide-quote">{post.text}</p>
|
||||
</div>
|
||||
<QuoteText text={post.text} open={textOpen} onOpenChange={setTextOpen} />
|
||||
)}
|
||||
{burst ? (
|
||||
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -251,7 +273,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
|
||||
</div>
|
||||
</div>
|
||||
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
|
||||
{media && post.text ? <CaptionText text={post.text} open={textOpen} onOpenChange={setTextOpen} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -348,6 +370,13 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo
|
||||
export function MobileFeed(p: FeedViewProps) {
|
||||
const reel = useRef<HTMLDivElement | null>(null);
|
||||
const [current, setCurrent] = useState(0);
|
||||
// The slide whose long text is open: the feed holds still so swipes scroll the text.
|
||||
const [expandedAt, setExpandedAt] = useState<number | null>(null);
|
||||
const locked = useRef(false);
|
||||
locked.current = expandedAt !== null;
|
||||
const onExpandedChange = useCallback((at: number, open: boolean) => {
|
||||
setExpandedAt((cur) => (open ? at : cur === at ? null : cur));
|
||||
}, []);
|
||||
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
|
||||
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
|
||||
const closeSheet = useCallback(() => setSheet(null), []);
|
||||
@@ -401,6 +430,8 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
let lastStep = 0;
|
||||
let from = 0;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Open text scrolls natively; the reel is overflow-hidden meanwhile.
|
||||
if (locked.current) return;
|
||||
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
|
||||
e.preventDefault();
|
||||
const now = performance.now();
|
||||
@@ -412,11 +443,11 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
};
|
||||
let startY: number | null = null;
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
|
||||
startY = e.touches.length === 1 && !locked.current ? e.touches[0].clientY : null;
|
||||
from = at();
|
||||
};
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (startY === null || e.touches.length) return;
|
||||
if (startY === null || e.touches.length || locked.current) return;
|
||||
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
|
||||
startY = null;
|
||||
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
|
||||
@@ -465,6 +496,7 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
className="reel"
|
||||
data-testid="feed-reel"
|
||||
data-current={index}
|
||||
data-locked={expandedAt !== null ? "true" : undefined}
|
||||
ref={(el) => {
|
||||
reel.current = el;
|
||||
setRef(p.listRef, el);
|
||||
@@ -483,7 +515,7 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
) : null}
|
||||
{p.status === "ready"
|
||||
? p.posts.flatMap((post, i) => {
|
||||
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} />;
|
||||
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} onExpandedChange={onExpandedChange} />;
|
||||
return i === featuredAt - 1
|
||||
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
|
||||
: [node];
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/** Quote sizes for a text-only post, largest first. The smallest one clamps and offers "Read more". */
|
||||
export const QUOTE_SIZES = [31, 26, 22, 19] as const;
|
||||
/** Space kept under a clamped quote for the "Read more" button (44px tall plus its gap). */
|
||||
export const QUOTE_MORE_RESERVE = 52;
|
||||
|
||||
export interface QuoteFit {
|
||||
size: number;
|
||||
/** Lines shown before "Read more"; 0 when the whole text fits. */
|
||||
lines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first quote size whose rendered text fits, or the smallest size with as
|
||||
* many whole lines as fit above the "Read more" button. `fits` measures the
|
||||
* rendered text at a size; `linesAt` counts the lines that fit at the smallest one.
|
||||
*/
|
||||
export function pickQuoteFit(fits: (size: number) => boolean, linesAt: (size: number) => number): QuoteFit {
|
||||
for (const size of QUOTE_SIZES) if (fits(size)) return { size, lines: 0 };
|
||||
const size = QUOTE_SIZES[QUOTE_SIZES.length - 1];
|
||||
return { size, lines: Math.max(1, Math.floor(linesAt(size))) };
|
||||
}
|
||||
|
||||
/** Re-runs `measure` when the box resizes, web fonts finish loading, or the theme changes. */
|
||||
function useRemeasure(box: React.RefObject<HTMLElement | null>, measure: () => void, deps: unknown[]) {
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
useEffect(() => {
|
||||
const el = box.current;
|
||||
if (!el) return;
|
||||
let frame = 0;
|
||||
const later = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(measure);
|
||||
};
|
||||
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(later) : null;
|
||||
ro?.observe(el);
|
||||
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
|
||||
void fonts?.ready.then(later);
|
||||
fonts?.addEventListener?.("loadingdone", later);
|
||||
const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(later) : null;
|
||||
mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
ro?.disconnect();
|
||||
mo?.disconnect();
|
||||
fonts?.removeEventListener?.("loadingdone", later);
|
||||
};
|
||||
}, [box, measure]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-only post: the serif quote steps down 31 → 26 → 22 → 19px until it fits
|
||||
* between the top bar and the author block. When it still does not fit, it
|
||||
* clamps and ends with "Read more", which opens the full text over the slide.
|
||||
*/
|
||||
export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const inner = useRef<HTMLDivElement>(null);
|
||||
const quote = useRef<HTMLParagraphElement>(null);
|
||||
const moreBtn = useRef<HTMLButtonElement>(null);
|
||||
const closeBtn = useRef<HTMLButtonElement>(null);
|
||||
const [fit, setFit] = useState<QuoteFit>({ size: QUOTE_SIZES[0], lines: 0 });
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const b = box.current;
|
||||
const q = quote.current;
|
||||
const w = inner.current;
|
||||
if (!b || !q || !w || !b.clientHeight) return;
|
||||
const apply = (f: QuoteFit) => {
|
||||
q.style.fontSize = `${f.size}px`;
|
||||
q.style.webkitLineClamp = f.lines ? String(f.lines) : "";
|
||||
q.classList.toggle("is-clamped", f.lines > 0);
|
||||
b.classList.toggle("is-clamped", f.lines > 0);
|
||||
};
|
||||
const result = pickQuoteFit(
|
||||
(size) => {
|
||||
apply({ size, lines: 0 });
|
||||
return w.offsetHeight <= b.clientHeight + 0.5;
|
||||
},
|
||||
() => {
|
||||
// Height left for the text: the box minus the quote mark, its gap and the button.
|
||||
const other = w.offsetHeight - q.offsetHeight;
|
||||
const lineHeight = parseFloat(getComputedStyle(q).lineHeight) || 19 * 1.2;
|
||||
return (b.clientHeight - other - QUOTE_MORE_RESERVE) / lineHeight;
|
||||
},
|
||||
);
|
||||
apply(result);
|
||||
setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
|
||||
}, []);
|
||||
useRemeasure(box, measure, [text]);
|
||||
|
||||
// Focus follows the reader: into it on open, back to "Read more" on close.
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open) closeBtn.current?.focus({ preventScroll: true });
|
||||
else if (wasOpen.current) moreBtn.current?.focus({ preventScroll: true });
|
||||
wasOpen.current = open;
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`slide-quote-w${fit.lines ? " is-clamped" : ""}`} ref={box} data-testid="slide-quote-box">
|
||||
<div className="slide-quote-in" ref={inner}>
|
||||
<span className="slide-quote-mark" aria-hidden="true">{"“"}</span>
|
||||
<p
|
||||
className={`slide-quote${fit.lines ? " is-clamped" : ""}`}
|
||||
ref={quote}
|
||||
data-fit-size={fit.size}
|
||||
style={{ fontSize: `${fit.size}px`, WebkitLineClamp: fit.lines || undefined }}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
{fit.lines ? (
|
||||
<button
|
||||
type="button"
|
||||
className="slide-more slide-more-quote"
|
||||
ref={moreBtn}
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(true)}
|
||||
>
|
||||
Read more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div
|
||||
className="slide-reader"
|
||||
data-testid="slide-reader"
|
||||
data-expanded-text="true"
|
||||
role="region"
|
||||
aria-label="Full post"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<div className="slide-reader-scroll" data-testid="slide-reader-scroll" tabIndex={0}>
|
||||
<p className="slide-reader-text">{text}</p>
|
||||
</div>
|
||||
<button type="button" className="slide-more slide-reader-close" ref={closeBtn} aria-expanded="true" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caption over a photo or video: three lines, then "more" when it is longer.
|
||||
* Open, the whole caption shows over the media on a darker scrim, scrolling
|
||||
* inside the slide when it is very long, with "less" to collapse it.
|
||||
*/
|
||||
export function CaptionText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const caption = useRef<HTMLParagraphElement>(null);
|
||||
const [long, setLong] = useState(false);
|
||||
const measure = useCallback(() => {
|
||||
const c = caption.current;
|
||||
// Measured only while clamped: an open caption is long by definition.
|
||||
if (!c || c.classList.contains("is-open") || !c.clientHeight) return;
|
||||
setLong(c.scrollHeight > c.clientHeight + 1);
|
||||
}, []);
|
||||
useRemeasure(caption, measure, [text]);
|
||||
useEffect(() => {
|
||||
if (!open && caption.current) caption.current.scrollTop = 0;
|
||||
}, [open]);
|
||||
return (
|
||||
<>
|
||||
<p
|
||||
className={`slide-text${open ? " is-open" : ""}`}
|
||||
ref={caption}
|
||||
data-expanded-text={open ? "true" : undefined}
|
||||
data-testid="slide-caption"
|
||||
tabIndex={open ? 0 : undefined}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{long || open ? (
|
||||
<button type="button" className="slide-more slide-more-caption" aria-expanded={open} onClick={() => onOpenChange(!open)}>
|
||||
{open ? "less" : "more"}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user