Skip to content

Commit f267657

Browse files
committed
feat: 상세 페이지 복귀 시 직전에 열었던 카드로 스크롤 복구
- sessionStorage 기반 returnFocus 유틸 추가 (rememberReturnFocus/takeReturnFocus) - prefersReducedMotion 유틸 추가로 중복 코드 제거 - SeriesCard에 onBeforeNavigate prop 추가 - Library/Series 페이지에 rAF 기반 복귀 스크롤 적용 - StrictMode effect 재실행에서 storage 소비 방지 - ref 맵 cleanup 처리로 stale entry 제거 - Series.module.css dead CSS(.volumeCard 계열) 제거 - returnFocus/reducedMotion 단위 테스트 및 Library/Series 통합 테스트 추가
1 parent af3bc66 commit f267657

10 files changed

Lines changed: 315 additions & 119 deletions

File tree

web/src/components/SeriesCard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface SeriesCardProps {
4242
extensionBadgeText?: string | null;
4343
extensionBadgePlacement?: "thumbnail" | "meta";
4444
navigateTo?: string;
45+
onBeforeNavigate?: () => void;
4546
}
4647

4748
export function SeriesCard({
@@ -59,6 +60,7 @@ export function SeriesCard({
5960
extensionBadgeText = null,
6061
extensionBadgePlacement = "thumbnail",
6162
navigateTo,
63+
onBeforeNavigate,
6264
}: SeriesCardProps) {
6365
const { t } = useTranslation();
6466
const navigate = useNavigate();
@@ -159,6 +161,8 @@ export function SeriesCard({
159161
const handleCardClick = (e: React.MouseEvent) => {
160162
if (e.defaultPrevented || window.getSelection()?.toString()) return;
161163

164+
onBeforeNavigate?.();
165+
162166
if (navigateTo) {
163167
navigate(navigateTo);
164168
return;

web/src/pages/Library.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
55
import type { Series } from "../types/series";
66
import { LibraryPage } from "./Library";
77
import styles from "./Library.module.css";
8+
import { rememberReturnFocus } from "../utils/returnFocus";
89

910
const { mocks, libraryStoreState, authStoreState } = vi.hoisted(() => {
1011
const libraryGetMock = vi.fn();
@@ -138,6 +139,7 @@ vi.mock("../components/common/LoadingSpinner", () => ({
138139
}));
139140

140141
const originalResizeObserver = globalThis.ResizeObserver;
142+
const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
141143
const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
142144
window.HTMLElement.prototype,
143145
"scrollIntoView",
@@ -272,6 +274,11 @@ describe("LibraryPage series index", () => {
272274
afterEach(() => {
273275
defaultRectSpy.mockRestore();
274276
globalThis.ResizeObserver = originalResizeObserver;
277+
if (originalMatchMediaDescriptor) {
278+
Object.defineProperty(window, "matchMedia", originalMatchMediaDescriptor);
279+
} else {
280+
Reflect.deleteProperty(window, "matchMedia");
281+
}
275282
if (originalScrollIntoViewDescriptor) {
276283
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", originalScrollIntoViewDescriptor);
277284
} else {
@@ -289,6 +296,38 @@ describe("LibraryPage series index", () => {
289296
}
290297
});
291298

299+
it("복귀한 라이브러리에서 직전에 열었던 시리즈 카드를 중앙에 맞춘다", async () => {
300+
rememberReturnFocus("library", "library-1", "series-b");
301+
302+
renderLibraryPage();
303+
304+
await screen.findByText("Beta");
305+
await waitFor(() => {
306+
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({
307+
behavior: "smooth",
308+
block: "center",
309+
});
310+
});
311+
});
312+
313+
it("동작 줄이기 설정에서는 복귀 카드를 즉시 중앙에 맞춘다", async () => {
314+
Object.defineProperty(window, "matchMedia", {
315+
configurable: true,
316+
value: vi.fn(() => ({ matches: true })),
317+
});
318+
rememberReturnFocus("library", "library-1", "series-b");
319+
320+
renderLibraryPage();
321+
322+
await screen.findByText("Beta");
323+
await waitFor(() => {
324+
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({
325+
behavior: "auto",
326+
block: "center",
327+
});
328+
});
329+
});
330+
292331
it("활성 목차 항목이 스크롤 영역 중앙에 오도록 이동한다", async () => {
293332
renderLibraryPage();
294333

web/src/pages/Library.tsx

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
getSeriesDisplayName,
2222
getSeriesGroupKey,
2323
} from "../utils/librarySeries";
24+
import { rememberReturnFocus, takeReturnFocus } from "../utils/returnFocus";
25+
import { prefersReducedMotion } from "../utils/reducedMotion";
2426
import styles from "./Library.module.css";
2527

2628
const POLL_INTERVAL_MS = 3000;
@@ -56,6 +58,7 @@ export function LibraryPage() {
5658
const loadSequenceRef = useRef(0);
5759
const lastFetchedIdRef = useRef<string | null>(null);
5860
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
61+
const seriesCardRefs = useRef<Record<string, HTMLDivElement>>({});
5962
const indexButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
6063
const scrollingToGroupRef = useRef<string | null>(null);
6164
const scrollReleaseTimeoutRef = useRef<number | null>(null);
@@ -139,6 +142,22 @@ export function LibraryPage() {
139142
}
140143
}, [id, refreshKey, loadData, fetchLibraries]);
141144

145+
useEffect(() => {
146+
if (!id || isLoading) return;
147+
148+
// App의 전역 scroll-to-top effect가 끝난 다음 프레임에 복귀 스크롤을 적용한다.
149+
// StrictMode의 effect 재실행에서도 취소된 프레임은 storage를 소비하지 않는다.
150+
const frameId = window.requestAnimationFrame(() => {
151+
const seriesId = takeReturnFocus("library", id);
152+
const target = seriesId ? seriesCardRefs.current[seriesId] : null;
153+
if (!target) return;
154+
155+
target.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center" });
156+
});
157+
158+
return () => window.cancelAnimationFrame(frameId);
159+
}, [id, isLoading, seriesList]);
160+
142161
// 스캔 중일 때 시리즈 목록 실시간 폴링
143162
// - isScanning: 이 페이지에서 직접 스캔 버튼을 눌렀을 때 (libraryAPI.scan은 동기 블로킹이므로 스토어 갱신 없음)
144163
// - currentLibraryScanStatus: 외부(다른 탭/스케줄러 등)에서 스캔이 시작된 경우
@@ -265,15 +284,12 @@ export function LibraryPage() {
265284
}
266285
const target = sectionRefs.current[groupKey];
267286
if (target) {
268-
const prefersReducedMotion =
269-
typeof window !== "undefined" &&
270-
typeof window.matchMedia === "function" &&
271-
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
272-
target.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "start" });
287+
const reducedMotion = prefersReducedMotion();
288+
target.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "start" });
273289
scrollReleaseTimeoutRef.current = window.setTimeout(() => {
274290
scrollingToGroupRef.current = null;
275291
scrollReleaseTimeoutRef.current = null;
276-
}, prefersReducedMotion ? 0 : INDEX_SCROLL_LOCK_MS);
292+
}, reducedMotion ? 0 : INDEX_SCROLL_LOCK_MS);
277293
} else {
278294
scrollingToGroupRef.current = null;
279295
}
@@ -668,6 +684,11 @@ export function LibraryPage() {
668684
<div
669685
key={series.id}
670686
ref={(node) => {
687+
if (node) {
688+
seriesCardRefs.current[series.id] = node;
689+
} else {
690+
delete seriesCardRefs.current[series.id];
691+
}
671692
if (index === 0) {
672693
sectionRefs.current[group.key] = node;
673694
}
@@ -681,6 +702,9 @@ export function LibraryPage() {
681702
progressStyle="overlay"
682703
showExtensionBadge
683704
onStatusChange={loadData}
705+
onBeforeNavigate={() => {
706+
if (id) rememberReturnFocus("library", id, series.id);
707+
}}
684708
/>
685709
</div>
686710
);

web/src/pages/Series.module.css

Lines changed: 5 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -24,101 +24,16 @@
2424
gap: 1rem;
2525
}
2626

27-
.volumeCard {
27+
/* ref 콜백을 위한 래퍼. grid item으로 동작하며 내부 SeriesCard가 너비를 채운다. */
28+
.volumeCardAnchor {
2829
display: flex;
2930
flex-direction: column;
30-
background: rgba(255, 255, 255, 0.03);
31-
border: 1px solid rgba(255, 255, 255, 0.08);
32-
border-radius: 12px;
33-
overflow: hidden;
34-
text-decoration: none;
35-
color: inherit;
36-
transition: all 0.2s;
37-
}
38-
39-
.volumeCard:hover {
40-
background: rgba(255, 255, 255, 0.06);
41-
border-color: rgba(102, 126, 234, 0.4);
42-
transform: translateY(-2px);
43-
}
44-
45-
.volumeCover {
46-
aspect-ratio: 5/8; /* 시리즈 카드와 동일하게 1:1.6 비율 */
47-
display: flex;
48-
align-items: center;
49-
justify-content: center;
50-
background: linear-gradient(135deg, #1e3a5f 0%, #2d3748 100%);
51-
color: #667eea;
52-
overflow: hidden;
53-
position: relative;
31+
min-width: 0;
5432
}
5533

56-
.volumeThumbnail {
34+
.volumeCardAnchor > * {
5735
width: 100%;
58-
height: 100%;
59-
object-fit: cover;
60-
transition: transform 0.3s ease;
61-
}
62-
63-
.volumeCard:hover .volumeThumbnail {
64-
transform: scale(1.05);
65-
}
66-
67-
/* 볼륨 카드 클릭 스타일 */
68-
.volumeCard {
69-
cursor: pointer;
70-
}
71-
72-
.volumeCard.loading {
73-
pointer-events: none;
74-
opacity: 0.7;
75-
}
76-
77-
/* 재생 오버레이 */
78-
.volumePlayOverlay {
79-
position: absolute;
80-
top: 0;
81-
left: 0;
82-
right: 0;
83-
bottom: 0;
84-
display: flex;
85-
align-items: center;
86-
justify-content: center;
87-
background: rgba(0, 0, 0, 0.4);
88-
opacity: 0;
89-
transition: opacity 0.2s ease;
90-
}
91-
92-
.volumeCard:hover .volumePlayOverlay {
93-
opacity: 1;
94-
}
95-
96-
.volumePlayOverlay .loadingSpinner.small {
97-
width: 24px;
98-
height: 24px;
99-
border-width: 2px;
100-
}
101-
102-
.volumeInfo {
103-
padding: 0.75rem;
104-
}
105-
106-
.volumeTitle {
107-
font-size: 0.9rem;
108-
font-weight: 500;
109-
margin: 0 0 0.25rem;
110-
overflow: hidden;
111-
text-overflow: ellipsis;
112-
display: -webkit-box;
113-
-webkit-line-clamp: 2;
114-
line-clamp: 2;
115-
-webkit-box-orient: vertical;
116-
}
117-
118-
.volumeNumber {
119-
font-size: 0.8rem;
120-
color: #718096;
121-
margin: 0;
36+
flex: 1 1 auto;
12237
}
12338

12439
/* 로딩 & 에러 */
@@ -157,17 +72,4 @@
15772
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
15873
gap: 0.75rem;
15974
}
160-
161-
.volumeInfo {
162-
padding: 0.5rem;
163-
}
164-
165-
.volumeTitle {
166-
font-size: 0.8rem;
167-
margin-bottom: 0.1rem;
168-
}
169-
170-
.volumeNumber {
171-
font-size: 0.7rem;
172-
}
17375
}

0 commit comments

Comments
 (0)