feat: add directional view transitions with scroll preservation in library view, closes #3357 (#3362)
This commit is contained in:
@@ -126,6 +126,7 @@
|
||||
"marked": "^15.0.12",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.1.6",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nunjucks": "^3.2.4",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ViewTransitions } from 'next-view-transitions';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
@@ -72,9 +73,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='twitter:image' content={previewImage} />
|
||||
</head>
|
||||
<body>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ interface BookshelfProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
@@ -69,6 +70,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
}) => {
|
||||
@@ -426,6 +428,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
import { navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -99,6 +99,7 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
|
||||
@@ -116,11 +117,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useTransitionRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
@@ -179,15 +180,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', group.id);
|
||||
setTimeout(() => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}, 0);
|
||||
handleLibraryNavigation(group.id);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isSelectMode, searchParams],
|
||||
[isSelectMode, handleLibraryNavigation],
|
||||
);
|
||||
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { MdChevronRight } from 'react-icons/md';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
@@ -82,7 +83,7 @@ const LibraryPageWithSearchParams = () => {
|
||||
};
|
||||
|
||||
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
|
||||
const router = useRouter();
|
||||
const router = useTransitionRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
@@ -134,6 +135,53 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`;
|
||||
|
||||
const saveScrollPosition = (group: string) => {
|
||||
const viewport = osRef.current?.osInstance()?.elements().viewport;
|
||||
if (viewport) {
|
||||
const scrollTop = viewport.scrollTop;
|
||||
sessionStorage.setItem(getScrollKey(group), scrollTop.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const restoreScrollPosition = useCallback((group: string) => {
|
||||
const savedPosition = sessionStorage.getItem(getScrollKey(group));
|
||||
if (savedPosition) {
|
||||
const scrollTop = parseInt(savedPosition, 10);
|
||||
const viewport = osRef.current?.osInstance()?.elements().viewport;
|
||||
if (viewport) {
|
||||
viewport.scrollTop = scrollTop;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Unified navigation function that handles scroll position and direction
|
||||
const handleLibraryNavigation = useCallback(
|
||||
(targetGroup: string) => {
|
||||
const currentGroup = searchParams?.get('group') || '';
|
||||
|
||||
// Save current scroll position BEFORE navigation
|
||||
saveScrollPosition(currentGroup);
|
||||
|
||||
// Detect and set navigation direction
|
||||
const direction = currentGroup && !targetGroup ? 'back' : 'forward';
|
||||
document.documentElement.setAttribute('data-nav-direction', direction);
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (targetGroup) {
|
||||
params.set('group', targetGroup);
|
||||
} else {
|
||||
params.delete('group');
|
||||
}
|
||||
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[searchParams, router],
|
||||
);
|
||||
|
||||
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
||||
useUICSS();
|
||||
|
||||
@@ -390,6 +438,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setCurrentGroupPath(groupName);
|
||||
}, [libraryBooks, searchParams, getGroupName]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
restoreScrollPosition(group);
|
||||
}, [searchParams, restoreScrollPosition]);
|
||||
|
||||
// Track current series/author group for navigation header
|
||||
useEffect(() => {
|
||||
const groupId = searchParams?.get('group') || '';
|
||||
@@ -746,19 +799,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
};
|
||||
|
||||
const handleNavigateToPath = (path: string | undefined) => {
|
||||
const group = path ? getGroupId(path) : '';
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (group) {
|
||||
params.set('group', group);
|
||||
} else {
|
||||
params.delete('group');
|
||||
}
|
||||
const group = path ? getGroupId(path) || '' : '';
|
||||
setIsSelectAll(false);
|
||||
setIsSelectNone(false);
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
setTimeout(() => {
|
||||
setCurrentGroupPath(path);
|
||||
}, 300);
|
||||
handleLibraryNavigation(group);
|
||||
};
|
||||
|
||||
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
|
||||
@@ -890,6 +934,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
|
||||
@@ -631,3 +631,100 @@ foliate-fxl {
|
||||
animation: settings-highlight-pulse 0.5s ease-in-out 2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* View Transitions for library navigation */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.3s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
html[data-nav-direction='forward'] ::view-transition-old(root) {
|
||||
animation-name: slide-fade-out-left;
|
||||
}
|
||||
|
||||
html[data-nav-direction='forward'] ::view-transition-new(root) {
|
||||
animation-name: slide-fade-in-right;
|
||||
}
|
||||
|
||||
html[data-nav-direction='back'] ::view-transition-old(root) {
|
||||
animation-name: slide-fade-out-right;
|
||||
}
|
||||
|
||||
html[data-nav-direction='back'] ::view-transition-new(root) {
|
||||
animation-name: slide-fade-in-left;
|
||||
}
|
||||
|
||||
/* Forward animations */
|
||||
@keyframes slide-fade-out-left {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-fade-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Back animations */
|
||||
@keyframes slide-fade-out-right {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-fade-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Simpler fade for reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.15s;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation-name: simple-fade-out !important;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: simple-fade-in !important;
|
||||
}
|
||||
|
||||
@keyframes simple-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes simple-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+16
@@ -269,6 +269,9 @@ importers:
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
next-view-transitions:
|
||||
specifier: ^0.3.5
|
||||
version: 0.3.5(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
nunjucks:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(chokidar@3.6.0)
|
||||
@@ -6341,6 +6344,13 @@ packages:
|
||||
neo-async@2.6.2:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
next-view-transitions@0.3.5:
|
||||
resolution: {integrity: sha512-yP8OPNydLmKpmE94hLurLGEzPsUy1uyl9iSv8oxaC2JwhSXTD86SVwk1NMMQT7Ado4kMENDJ7fNBIXHy3GU/Lg==}
|
||||
peerDependencies:
|
||||
next: '>=14.0.0'
|
||||
react: '>=18.2.0 || ^19.0.0'
|
||||
react-dom: '>=18.2.0 || ^19.0.0'
|
||||
|
||||
next@16.1.6:
|
||||
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
@@ -15296,6 +15306,12 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next-view-transitions@0.3.5(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
|
||||
Reference in New Issue
Block a user