From b283ba74da0c4c13c52c78188144d5956ebd8f9d Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 23:49:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B6=88=E6=81=AF=E6=B0=94=E6=B3=A1?= =?UTF-8?q?=E9=95=BF=E6=96=87=E5=AD=97=E6=94=AF=E6=8C=81=E3=80=8C=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E5=85=A8=E9=83=A8/=E6=94=B6=E8=B5=B7=E5=85=A8?= =?UTF-8?q?=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",