From 757bf71cdf1722f3b73ef49c718539ee7c3d8937 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 8 Jan 2026 14:44:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=EC=97=90=20=EC=9D=B4=EC=A0=84/=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지 2개 이상일 때 좌우 화살표 버튼 표시 - 키보드 네비게이션 지원 (← → 화살표, ESC 닫기) - 현재 이미지 위치 카운터 표시 (1 / 3) - 순환 네비게이션 (마지막에서 다음 → 첫 번째) Closes #95 Co-Authored-By: Claude Opus 4.5 --- src/ui/components/TimelineItem.tsx | 81 +++++++++++++++++++++++++++--- src/ui/styles/components.css | 43 ++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 02631a2..dd9134a 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -37,7 +37,7 @@ export const TimelineItem = ({ const notification = status.notification; const displayStatus = notification?.target ?? status.reblog ?? status; const boostedBy = notification ? null : status.reblog ? status.boostedBy : null; - const [activeImageUrl, setActiveImageUrl] = useState(null); + const [activeImageIndex, setActiveImageIndex] = useState(null); const [showContent, setShowContent] = useState(() => displayStatus.spoilerText.length === 0); const [imageZoom, setImageZoom] = useState(1); const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 }); @@ -52,6 +52,44 @@ export const TimelineItem = ({ ); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const attachments = displayStatus.mediaAttachments; + const activeImageUrl = activeImageIndex !== null ? attachments[activeImageIndex]?.url ?? null : null; + + const goToPrevImage = useCallback(() => { + if (activeImageIndex === null || attachments.length <= 1) return; + const prevIndex = activeImageIndex === 0 ? attachments.length - 1 : activeImageIndex - 1; + setActiveImageIndex(prevIndex); + setImageZoom(1); + setImageOffset({ x: 0, y: 0 }); + }, [activeImageIndex, attachments.length]); + + const goToNextImage = useCallback(() => { + if (activeImageIndex === null || attachments.length <= 1) return; + const nextIndex = activeImageIndex === attachments.length - 1 ? 0 : activeImageIndex + 1; + setActiveImageIndex(nextIndex); + setImageZoom(1); + setImageOffset({ x: 0, y: 0 }); + }, [activeImageIndex, attachments.length]); + + useEffect(() => { + if (activeImageIndex === null) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + goToPrevImage(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + goToNextImage(); + } else if (event.key === "Escape") { + event.preventDefault(); + setActiveImageIndex(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [activeImageIndex, goToPrevImage, goToNextImage]); + const previewCard = displayStatus.card; const mentionNames = useMemo(() => { if (!displayStatus.mentions || displayStatus.mentions.length === 0) { @@ -770,7 +808,7 @@ export const TimelineItem = ({ ) : null} {showContent - ? attachments.map((item) => ( + ? attachments.map((item, index) => ( + {attachments.length > 1 ? ( + + ) : null} + {attachments.length > 1 ? ( + + ) : null} 첨부 이미지 원본 + {attachments.length > 1 ? ( +
+ {(activeImageIndex ?? 0) + 1} / {attachments.length} +
+ ) : null} ) : null} diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 87d13a3..9f19f18 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1529,6 +1529,49 @@ button.ghost { z-index: 2; } +.image-modal-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.5); + color: white; + border: none; + border-radius: 50%; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2; + transition: background 0.2s; +} + +.image-modal-nav:hover { + background: rgba(0, 0, 0, 0.7); +} + +.image-modal-nav-prev { + left: 12px; +} + +.image-modal-nav-next { + right: 12px; +} + +.image-modal-counter { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 6px 14px; + border-radius: 16px; + font-size: 14px; + z-index: 2; +} + .confirm-modal { position: fixed; inset: 0;