From 1f8772f645faea4efdf5da95a8c9f610b7b922c4 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 17:50:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=B8=E5=86=8C=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=BF=9B=E5=A4=A7=E5=9B=BE=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E5=99=A8,=E7=A7=BB=E9=99=A4=E3=80=8C=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E3=80=8D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AlbumBubble: 点 +N 直接打开全屏查看器(从该图开始),删除中间的选择列表弹窗 - ImageLightbox: 底部加可滑动缩略图条(当前高亮+自动滚动定位),顶栏加下载按钮 - 下载按钮保证此前藏在 +N 列表里的图片仍可下载 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../messageStream/bubbles/AlbumBubble.tsx | 165 +------------ .../messageStream/overlays/ImageLightbox.tsx | 226 ++++++++++++++---- 2 files changed, 188 insertions(+), 203 deletions(-) diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 40a22b5..7b91ccf 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -1,17 +1,10 @@ -import { LoaderCircle, X } from "lucide-react"; -import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { useI18n } from "../../../i18n"; -import type { Attachment, Post } from "../../../types/post"; +import type { Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; -import { downloadAttachment } from "../utils/downloadFile"; -import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; -import { useToast } from "../../Toast"; const MAX_VISIBLE = 4; @@ -26,135 +19,9 @@ function albumItemClass(index: number, count: number) { return ""; } -function ImageListDownloadButton({ - postId, - attachment, -}: { - postId: string; - attachment: Attachment; -}) { - const { t } = useI18n(); - const { showToast } = useToast(); - const [isDownloading, setIsDownloading] = useState(false); - - const handleDownload = async () => { - if (isDownloading) return; - setIsDownloading(true); - try { - await downloadAttachment(postId, attachment.id, attachment.filename); - showToast(t("downloadOk")); - } catch { - showToast(t("downloadFail"), "error"); - } finally { - setIsDownloading(false); - } - }; - - return ( - - ); -} - -function ImageListDialog({ - postId, - images, - onClose, - onPick, -}: { - postId: string; - images: Attachment[]; - onClose: () => void; - onPick: (index: number) => void; -}) { - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - return createPortal( -
-
e.stopPropagation()} - > -
-
选择图片
- -
-
- {images.map((image, index) => ( -
- - -
- ))} -
-
-
, - document.body, - ); -} - export function AlbumBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); const { lang } = useI18n(); - const [listOpen, setListOpen] = useState(false); const images = post.attachments; const text = postDisplayText(post, lang); const visible = images.slice(0, MAX_VISIBLE); @@ -176,21 +43,22 @@ export function AlbumBubble({ post }: { post: Post }) { > @@ -206,17 +74,6 @@ export function AlbumBubble({ post }: { post: Post }) { {autolink(text)} ) : null} - {listOpen ? ( - setListOpen(false)} - onPick={(index) => { - setListOpen(false); - openLightbox(images, index, text, post.id); - }} - /> - ) : null} ); } diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index eaacad2..62241e7 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -8,9 +8,14 @@ import { type PropsWithChildren, } from "react"; import { createPortal } from "react-dom"; -import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react"; import type { Attachment } from "../../../types/post"; +import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; +import { useI18n } from "../../../i18n"; +import { useToast } from "../../Toast"; +import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; +import { downloadAttachment } from "../utils/downloadFile"; type LightboxState = { images: Attachment[]; @@ -65,6 +70,7 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { images={state.images} startIndex={state.index} caption={state.caption} + postId={state.postId} onClose={closeLightbox} /> ) : null} @@ -72,15 +78,118 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { ); } +function LightboxDownloadButton({ + postId, + attachment, +}: { + postId: string; + attachment: Attachment; +}) { + const { t } = useI18n(); + const { showToast } = useToast(); + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + if (isDownloading) return; + setIsDownloading(true); + try { + await downloadAttachment(postId, attachment.id, attachment.filename); + showToast(t("downloadOk")); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } + }; + + return ( + + ); +} + +function Filmstrip({ + images, + index, + onSelect, +}: { + images: Attachment[]; + index: number; + onSelect: (i: number) => void; +}) { + const activeRef = useRef(null); + + useEffect(() => { + activeRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + }, [index]); + + return ( +
e.stopPropagation()} + > +
+ {images.map((image, i) => { + const active = i === index; + return ( + + ); + })} +
+
+ ); +} + function LightboxView({ images, startIndex, caption: captionText, + postId, onClose, }: { images: Attachment[]; startIndex: number; caption?: string; + postId?: string; onClose: () => void; }) { const [index, setIndex] = useState(startIndex); @@ -118,6 +227,7 @@ function LightboxView({ const current = images[index]; const caption = captionText?.trim(); const showCaption = !!caption && isCaptionVisible; + const hasMany = images.length > 1; if (!current) return null; return createPortal( @@ -127,51 +237,64 @@ function LightboxView({ role="dialog" aria-modal="true" > - - - {images.length > 1 ? ( - <> +
+ {hasMany ? ( + + {index + 1} / {images.length} + + ) : null} +
+
+ {postId ? ( + + ) : null} - -
- {index + 1} / {images.length} -
- - ) : null} +
+ + + {/* Image stage */} +
+ {hasMany ? ( + <> + + + + ) : null} -
{ e.stopPropagation(); if (caption) setIsCaptionVisible((visible) => !visible); @@ -192,21 +315,26 @@ function LightboxView({ {current.filename}
+ + {showCaption ? ( +
e.stopPropagation()} + > +
+ {autolink(caption)} +
+
+ ) : null}
- {showCaption ? ( -
e.stopPropagation()} - > -
- {autolink(caption)} -
-
+ {/* Bottom thumbnail filmstrip */} + {hasMany ? ( + ) : null}
, document.body,