From a7792c117d1519f30e45546ec6a69c515b8aaf09 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 19:04:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E8=B5=84=E6=96=99=E6=B5=81=E6=8F=90?= =?UTF-8?q?=E6=97=A9=E6=98=BE=E7=8E=B0=E5=86=85=E5=AE=B9,=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E7=A9=BA=E7=99=BD=E7=BC=BA=E5=8F=A3=E8=A2=AB=E8=AF=AF?= =?UTF-8?q?=E8=AE=A4=E4=B8=BA=E5=88=B0=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reveal 加 300px 前置 viewport margin,内容在进入视口前淡入; 无限滚动预加载触发线 200px → 1000px,下一页提前请求。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/messageStream/MessageStream.tsx | 4 +++- src/motion/Reveal.tsx | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 42091bc..e8f60b9 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -62,7 +62,9 @@ export function MessageStream({ scope }: MessageStreamProps) { } } }, - { rootMargin: "200px" }, + // Prefetch the next page well before the sentinel is visible so content + // is already in place as the user scrolls — no blank gap at the bottom. + { rootMargin: "1000px" }, ); io.observe(el); return () => io.disconnect(); diff --git a/src/motion/Reveal.tsx b/src/motion/Reveal.tsx index 8c367c0..f527361 100644 --- a/src/motion/Reveal.tsx +++ b/src/motion/Reveal.tsx @@ -11,6 +11,13 @@ type RevealProps = { once?: boolean; /** Fraction of the element that must be visible to trigger (0-1). */ amount?: number; + /** + * Root margin for the in-view trigger (same semantics as IntersectionObserver + * `rootMargin`). The default positive bottom value reveals each block ~300px + * *before* it scrolls into view, so long feeds never show a blank gap that + * reads as "the bottom" before content fades in. + */ + margin?: string; }; /** @@ -24,6 +31,7 @@ export function Reveal({ className, once = true, amount = 0.15, + margin = "0px 0px 300px 0px", }: RevealProps) { return ( {children} From b283ba74da0c4c13c52c78188144d5956ebd8f9d Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 23:49:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=B6=88=E6=81=AF=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=E9=95=BF=E6=96=87=E5=AD=97=E6=94=AF=E6=8C=81=E3=80=8C?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=85=A8=E9=83=A8/=E6=94=B6=E8=B5=B7?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E3=80=8D=E6=8A=98=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CollapsibleText 组件:超过 8 行文字自动折叠,按行高对齐裁切,底部柔和渐隐遮罩暗示有更多内容 - 折叠按钮左对齐,胶囊样式 + chevron 图标,hover 浅背景高亮,点击 chevron 旋转 180° - 应用到全部 5 种气泡:Text/ImageWithText/Album/Video/FileDoc - 动画统一使用 motion 包的 EASE_OUT 缓动,尊重 reducedMotion=user - i18n 七种语言新增 showMore/showLess 文案 --- .../messageStream/CollapsibleText.tsx | 127 ++++++++++++++++++ .../messageStream/bubbles/AlbumBubble.tsx | 8 +- .../messageStream/bubbles/FileDocBubble.tsx | 5 +- .../bubbles/ImageWithTextBubble.tsx | 8 +- .../messageStream/bubbles/TextBubble.tsx | 5 +- .../messageStream/bubbles/VideoBubble.tsx | 15 ++- src/i18n.tsx | 14 ++ 7 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 src/components/messageStream/CollapsibleText.tsx diff --git a/src/components/messageStream/CollapsibleText.tsx b/src/components/messageStream/CollapsibleText.tsx new file mode 100644 index 0000000..aac5c48 --- /dev/null +++ b/src/components/messageStream/CollapsibleText.tsx @@ -0,0 +1,127 @@ +import { AnimatePresence, m } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { useI18n } from "../../i18n"; +import { EASE_OUT } from "../../motion"; + +/** + * Collapsible long-text container. + * + * When the rendered text overflows `collapsedLines`: + * 1. The text is clipped to a clean line boundary. + * 2. A soft gradient fade hints that more content is below. + * 3. A centered pill button with a chevron icon offers a clear tap target. + * + * The clipped height is `collapsedLines × computed line-height` so the cut + * always lands at a line boundary — no half-cut characters. + * + * Motion uses the shared `EASE_OUT` curve and respects MotionProvider's + * global `reducedMotion="user"`. + */ +export function CollapsibleText({ + children, + className = "", + wrapperClassName = "", + collapsedLines = 8, +}: { + children: ReactNode; + /** Typography classes applied to the text container. */ + className?: string; + /** Outer wrapper classes (padding, etc.). */ + wrapperClassName?: string; + collapsedLines?: number; +}) { + const { t } = useI18n(); + const innerRef = useRef(null); + const [clampedHeight, setClampedHeight] = useState(0); + const [fullHeight, setFullHeight] = useState(0); + const [needsToggle, setNeedsToggle] = useState(false); + const [expanded, setExpanded] = useState(false); + + const measure = () => { + const el = innerRef.current; + if (!el) return; + const lh = parseFloat(getComputedStyle(el).lineHeight) || 24; + const ch = Math.round(lh * collapsedLines); + const fh = el.scrollHeight; + setClampedHeight(ch); + setFullHeight(fh); + setNeedsToggle(fh > ch + 4); + }; + + useLayoutEffect(() => { + measure(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + if (innerRef.current) ro.observe(innerRef.current); + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, collapsedLines]); + + useEffect(() => { + const id = window.requestAnimationFrame(measure); + return () => window.cancelAnimationFrame(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, collapsedLines]); + + const isClamped = needsToggle && !expanded; + const targetHeight = needsToggle + ? expanded + ? fullHeight + : clampedHeight + : undefined; + + return ( +
+ +
+ {children} +
+ + {/* Soft fade hints "more content below" when clamped. */} + + {isClamped ? ( + + ) : null} + +
+ + {needsToggle ? ( + + ) : null} +
+ ); +} diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 2347969..b7bd034 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -5,6 +5,7 @@ import { BubbleImage } from "../BubbleImage"; import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; const MAX_VISIBLE = 4; @@ -71,9 +72,12 @@ export function AlbumBubble({ post }: { post: Post }) { })} {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 864e07f..ada2c61 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -11,6 +11,7 @@ import { } from "../utils/filenameDisplay"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; import { useToast } from "../../Toast"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { @@ -97,9 +98,9 @@ export function FileDocBubble({ post }: { post: Post }) { ))} {text ? ( -
+ {text} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index a003fd7..a46c7b2 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -5,6 +5,7 @@ import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; export function ImageWithTextBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); @@ -30,9 +31,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) { {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/TextBubble.tsx b/src/components/messageStream/bubbles/TextBubble.tsx index bfb8523..abc1094 100644 --- a/src/components/messageStream/bubbles/TextBubble.tsx +++ b/src/components/messageStream/bubbles/TextBubble.tsx @@ -2,12 +2,13 @@ import type { Post } from "../../../types/post"; import { useI18n } from "../../../i18n"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; export function TextBubble({ post }: { post: Post }) { const { lang } = useI18n(); return ( -
+ {autolink(postDisplayText(post, lang))} -
+ ); } diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 9832d4b..440b384 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -7,6 +7,7 @@ import type { Attachment, Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; +import { CollapsibleText } from "../CollapsibleText"; import { downloadAttachment } from "../utils/downloadFile"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; @@ -355,9 +356,12 @@ export function VideoBubble({ post }: { post: Post }) { })} {text ? ( -
+ {autolink(text)} -
+ ) : null} {listOpen ? ( {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/i18n.tsx b/src/i18n.tsx index caddf20..72f1887 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -45,6 +45,8 @@ const zhDict: Dict = { downloading: "下载中…", downloadOk: "下载完成", downloadFail: "下载失败,请重试", + showMore: "展开全部", + showLess: "收起全部", share: "分享", langLabel: "语言", admin: "后台", @@ -175,6 +177,8 @@ const enDict: Dict = { downloading: "Downloading…", downloadOk: "Download complete", downloadFail: "Download failed, please retry", + showMore: "Show all", + showLess: "Show less", share: "Share", langLabel: "Language", admin: "Admin", @@ -291,6 +295,8 @@ const languageNames: Record = { }, ja: { brand: "ARK ライブラリー", + showMore: "すべて表示", + showLess: "閉じる", lang_zh_CN: "中国語", lang_en: "英語", lang_ja: "日本語", @@ -301,6 +307,8 @@ const languageNames: Record = { }, ko: { brand: "ARK 라이브러리", + showMore: "모두 보기", + showLess: "접기", lang_zh_CN: "중국어", lang_en: "영어", lang_ja: "일본어", @@ -311,6 +319,8 @@ const languageNames: Record = { }, vi: { brand: "Thư viện ARK", + showMore: "Xem tất cả", + showLess: "Thu gọn", lang_zh_CN: "Tiếng Trung", lang_en: "Tiếng Anh", lang_ja: "Tiếng Nhật", @@ -321,6 +331,8 @@ const languageNames: Record = { }, id: { brand: "Perpustakaan ARK", + showMore: "Lihat semua", + showLess: "Tutup", lang_zh_CN: "Bahasa Tionghoa", lang_en: "Bahasa Inggris", lang_ja: "Bahasa Jepang", @@ -331,6 +343,8 @@ const languageNames: Record = { }, ms: { brand: "Perpustakaan ARK", + showMore: "Lihat semua", + showLess: "Tutup", lang_zh_CN: "Bahasa Cina", lang_en: "Bahasa Inggeris", lang_ja: "Bahasa Jepun", From 64a41359b4222a56a3b22c73997ffc9460f8fe24 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 23:58:14 +0800 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=E5=B1=95=E5=BC=80/=E6=94=B6?= =?UTF-8?q?=E8=B5=B7=E6=8C=89=E9=92=AE=E5=8E=BB=E6=8E=89=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E8=83=B6=E5=9B=8A=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=BB=85=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=96=87=E5=AD=97+chevron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/messageStream/CollapsibleText.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/messageStream/CollapsibleText.tsx b/src/components/messageStream/CollapsibleText.tsx index aac5c48..b0320a4 100644 --- a/src/components/messageStream/CollapsibleText.tsx +++ b/src/components/messageStream/CollapsibleText.tsx @@ -110,7 +110,7 @@ export function CollapsibleText({ type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} - className="group mt-1.5 -ml-2 inline-flex h-8 items-center gap-1 rounded-full px-2 text-[13px] font-medium text-ark-gold transition-colors duration-200 hover:bg-white/5 hover:text-ark-gold2" + className="group mt-1.5 inline-flex h-8 items-center gap-1 text-[13px] font-medium text-ark-gold transition-colors duration-200 hover:text-ark-gold2" > {expanded ? t("showLess") : t("showMore")}