mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
fix(ux): optimize scrolling UX for the bookshelf and sidebar content (#3849)
This commit is contained in:
parent
8df8bc8b4a
commit
ef97a8ed02
14 changed files with 301 additions and 162 deletions
|
|
@ -20,6 +20,9 @@
|
|||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
|
||||
## Architecture Notes
|
||||
- foliate-js is a git submodule at `packages/foliate-js/`
|
||||
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
name: Virtuoso + OverlayScrollbars pattern
|
||||
description: How to integrate OverlayScrollbars with react-virtuoso for overlay scrollbars on Android/iOS webviews
|
||||
type: reference
|
||||
originSessionId: 9da59a46-3dff-4a77-b7a4-8de4d07297b6
|
||||
---
|
||||
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
|
||||
|
||||
## Migration from `customScrollParent`
|
||||
|
||||
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
|
||||
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
|
||||
- Remove the outer `OverlayScrollbarsComponent` wrapper
|
||||
- Use `scrollerRef` instead to capture Virtuoso's scroller element
|
||||
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
|
||||
|
||||
## Boilerplate
|
||||
|
||||
```tsx
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
// Inside the component:
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
|
||||
const div = el instanceof HTMLElement ? el : null;
|
||||
setScroller(div);
|
||||
// If parent needs the scroller (e.g. for pull-to-refresh):
|
||||
onScrollerRef?.(div as HTMLDivElement | null);
|
||||
}, [onScrollerRef]);
|
||||
```
|
||||
|
||||
## JSX structure
|
||||
|
||||
```tsx
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
|
||||
<Virtuoso
|
||||
scrollerRef={handleScrollerRef}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={items.length}
|
||||
itemContent={renderItem}
|
||||
overscan={200}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
|
||||
|
||||
## Footer spacer
|
||||
|
||||
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
|
||||
|
||||
```tsx
|
||||
const VIRTUOSO_COMPONENTS = {
|
||||
List: MyListComponent,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
|
||||
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
|
||||
- `initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
|
||||
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
|
||||
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
|
||||
|
||||
## Used in
|
||||
|
||||
- `src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
|
||||
- `src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
|
||||
|
|
@ -3,6 +3,8 @@ import * as React from 'react';
|
|||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import {
|
||||
Virtuoso,
|
||||
VirtuosoGrid,
|
||||
|
|
@ -54,13 +56,7 @@ interface BookshelfProps {
|
|||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
isSelectNone: boolean;
|
||||
/**
|
||||
* The DOM element whose `overflow-y: auto` backs the bookshelf scroll.
|
||||
* Passed to react-virtuoso as `customScrollParent` so virtualization uses
|
||||
* the parent page scroller (and existing pull-to-refresh / scroll save /
|
||||
* restore logic keeps working).
|
||||
*/
|
||||
scrollParentEl: HTMLDivElement | null;
|
||||
onScrollerRef: (el: HTMLDivElement | null) => void;
|
||||
handleImportBooks: () => void;
|
||||
handleBookDownload: (
|
||||
book: Book,
|
||||
|
|
@ -91,12 +87,6 @@ const BOOKSHELF_GRID_CLASSES =
|
|||
|
||||
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
|
||||
|
||||
/**
|
||||
* Custom List component for VirtuosoGrid. Tags the wrapping element with the
|
||||
* `transform-wrapper` class that `usePullToRefresh` transforms during a pull
|
||||
* gesture, and applies the runtime `libraryColumns` override when the user
|
||||
* has opted out of the responsive auto grid.
|
||||
*/
|
||||
const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
GridListProps & { context?: BookshelfListContext }
|
||||
|
|
@ -118,11 +108,6 @@ const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.fo
|
|||
));
|
||||
BookshelfGridList.displayName = 'BookshelfGridList';
|
||||
|
||||
/**
|
||||
* Custom List component for the linear Virtuoso (list view). Same purpose as
|
||||
* BookshelfGridList — carries the `transform-wrapper` class for pull-to-
|
||||
* refresh.
|
||||
*/
|
||||
const BookshelfLinearList: Components['List'] = React.forwardRef<HTMLDivElement, ListProps>(
|
||||
({ children, style, 'data-testid': testId }, ref) => (
|
||||
<div ref={ref} data-testid={testId} className={BOOKSHELF_LIST_CLASSES} style={style}>
|
||||
|
|
@ -134,9 +119,11 @@ BookshelfLinearList.displayName = 'BookshelfLinearList';
|
|||
|
||||
const GRID_VIRTUOSO_COMPONENTS: GridComponents<BookshelfListContext> = {
|
||||
List: BookshelfGridList,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
const LIST_VIRTUOSO_COMPONENTS: Components = {
|
||||
List: BookshelfLinearList,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
|
@ -144,7 +131,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
|||
isSelectMode,
|
||||
isSelectAll,
|
||||
isSelectNone,
|
||||
scrollParentEl,
|
||||
onScrollerRef,
|
||||
handleImportBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
|
|
@ -475,6 +462,40 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// OverlayScrollbars + Virtuoso integration: Virtuoso manages its own
|
||||
// scroller; OverlayScrollbars wraps it for overlay scrollbar rendering.
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
// Expose the Virtuoso scroller to the parent for pull-to-refresh & scroll save.
|
||||
const handleScrollerRef = useCallback(
|
||||
(el: HTMLElement | Window | null) => {
|
||||
const div = el instanceof HTMLElement ? el : null;
|
||||
setScroller(div);
|
||||
onScrollerRef(div as HTMLDivElement | null);
|
||||
},
|
||||
[onScrollerRef],
|
||||
);
|
||||
|
||||
const selectedBooks = getSelectedBooks();
|
||||
const isGridMode = viewMode === 'grid';
|
||||
const hasItems = sortedBookshelfItems.length > 0;
|
||||
|
|
@ -586,29 +607,31 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
|||
tabIndex={-1}
|
||||
role='main'
|
||||
aria-label={_('Bookshelf')}
|
||||
className='bookshelf focus:outline-none'
|
||||
className='bookshelf min-h-0 flex-grow focus:outline-none'
|
||||
>
|
||||
{scrollParentEl && hasItems && isGridMode && (
|
||||
<VirtuosoGrid<unknown, BookshelfListContext>
|
||||
customScrollParent={scrollParentEl}
|
||||
overscan={200}
|
||||
totalCount={gridTotalCount}
|
||||
components={GRID_VIRTUOSO_COMPONENTS}
|
||||
context={listContext}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderBookshelfItem}
|
||||
/>
|
||||
)}
|
||||
{scrollParentEl && hasItems && !isGridMode && (
|
||||
<Virtuoso
|
||||
customScrollParent={scrollParentEl}
|
||||
overscan={200}
|
||||
totalCount={sortedBookshelfItems.length}
|
||||
components={LIST_VIRTUOSO_COMPONENTS}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderBookshelfItem}
|
||||
/>
|
||||
)}
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
|
||||
{hasItems && isGridMode && (
|
||||
<VirtuosoGrid<unknown, BookshelfListContext>
|
||||
overscan={200}
|
||||
totalCount={gridTotalCount}
|
||||
components={GRID_VIRTUOSO_COMPONENTS}
|
||||
context={listContext}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderBookshelfItem}
|
||||
scrollerRef={handleScrollerRef}
|
||||
/>
|
||||
)}
|
||||
{hasItems && !isGridMode && (
|
||||
<Virtuoso
|
||||
overscan={200}
|
||||
totalCount={sortedBookshelfItems.length}
|
||||
components={LIST_VIRTUOSO_COMPONENTS}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderBookshelfItem}
|
||||
scrollerRef={handleScrollerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
|
|
|
|||
|
|
@ -134,15 +134,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
|||
const viewSettings = settings.globalViewSettings;
|
||||
const demoBooks = useDemoBooks();
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
// Mirror `scrollRef` in state so react-virtuoso's `customScrollParent` (which
|
||||
// only reads the prop once per mount cycle) always sees a real element
|
||||
// rather than `null` on the Bookshelf's first render.
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
const attachScrollRef = useCallback((el: HTMLDivElement | null) => {
|
||||
const handleScrollerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
scrollRef.current = el;
|
||||
setScrollEl(el);
|
||||
}, []);
|
||||
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`;
|
||||
|
|
@ -949,18 +944,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
|||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div
|
||||
ref={attachScrollRef}
|
||||
aria-label={_('Your Bookshelf')}
|
||||
className='library-scroller flex-grow'
|
||||
>
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx('scroll-container drop-zone flex-grow', isDragging && 'drag-over')}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingTop: '0px',
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingBottom: `${insets.bottom}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
|
|
@ -970,7 +962,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
|||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
scrollParentEl={scrollEl}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
|
|
|
|||
|
|
@ -51,14 +51,18 @@ const Notebook: React.FC = ({}) => {
|
|||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<BookNote[] | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(isMobile);
|
||||
|
||||
const {
|
||||
panelRef: notebookRef,
|
||||
overlayRef,
|
||||
panelHeight: notebookHeight,
|
||||
handleVerticalDragStart,
|
||||
} = useSwipeToDismiss(() => setNotebookVisible(false));
|
||||
const isMobile = window.innerWidth < 640;
|
||||
} = useSwipeToDismiss(
|
||||
() => setNotebookVisible(false),
|
||||
(data) => setIsFullHeightInMobile(data.clientY < 44),
|
||||
);
|
||||
|
||||
const onNavigateEvent = async () => {
|
||||
const pinButton = document.querySelector('.sidebar-pin-btn');
|
||||
|
|
@ -254,7 +258,7 @@ const Notebook: React.FC = ({}) => {
|
|||
ref={notebookRef}
|
||||
className={clsx(
|
||||
'notebook-container right-0 flex min-w-60 select-none flex-col',
|
||||
'full-height font-sans text-base font-normal sm:text-sm',
|
||||
'full-height font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
|
||||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
||||
isNotebookPinned ? 'z-20' : 'z-[45] shadow-2xl',
|
||||
|
|
@ -267,9 +271,11 @@ const Notebook: React.FC = ({}) => {
|
|||
width: isMobile ? '100%' : `${notebookWidth}`,
|
||||
maxWidth: isMobile ? '100%' : `${MAX_NOTEBOOK_WIDTH * 100}%`,
|
||||
position: isMobile ? 'fixed' : isNotebookPinned ? 'relative' : 'absolute',
|
||||
paddingTop: systemUIVisible
|
||||
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
|
||||
: `${safeAreaInsets?.top || 0}px`,
|
||||
paddingTop: isFullHeightInMobile
|
||||
? systemUIVisible
|
||||
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
|
||||
: `${safeAreaInsets?.top || 0}px`
|
||||
: '0px',
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
|
|
@ -305,7 +311,7 @@ const Notebook: React.FC = ({}) => {
|
|||
aria-label={_('Resize Notebook')}
|
||||
aria-orientation='vertical'
|
||||
aria-valuenow={notebookHeight.current}
|
||||
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
|
||||
className='drag-handle flex h-6 max-h-6 min-h-6 w-full cursor-row-resize items-center justify-center'
|
||||
onMouseDown={handleVerticalDragStart}
|
||||
onTouchStart={handleVerticalDragStart}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { useReaderStore } from '@/store/readerStore';
|
|||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import TOCView from './TOCView';
|
||||
import BooknoteView from './BooknoteView';
|
||||
|
|
@ -75,7 +77,14 @@ const SidebarContent: React.FC<{
|
|||
{targetTab === 'history' ? (
|
||||
<ChatHistoryView bookKey={sideBarBookKey} />
|
||||
) : (
|
||||
<div className='min-h-0 flex-1'>
|
||||
<OverlayScrollbarsComponent
|
||||
className='min-h-0 flex-1'
|
||||
options={{
|
||||
scrollbars: { autoHide: 'scroll', clickScroll: true },
|
||||
showNativeOverlaidScrollbars: false,
|
||||
}}
|
||||
defer
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'scroll-container h-full transition-opacity duration-300 ease-in-out',
|
||||
|
|
@ -89,21 +98,13 @@ const SidebarContent: React.FC<{
|
|||
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
{targetTab === 'annotations' && (
|
||||
<div className='sidebar-scroller h-full'>
|
||||
<BooknoteView
|
||||
type='annotation'
|
||||
toc={bookDoc.toc ?? []}
|
||||
bookKey={sideBarBookKey}
|
||||
/>
|
||||
</div>
|
||||
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
{targetTab === 'bookmarks' && (
|
||||
<div className='sidebar-scroller h-full'>
|
||||
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
</div>
|
||||
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const SideBar = ({}) => {
|
|||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const searchTermRef = useRef(searchTerm);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(isMobile);
|
||||
const {
|
||||
sideBarWidth,
|
||||
isSideBarPinned,
|
||||
|
|
@ -74,7 +75,10 @@ const SideBar = ({}) => {
|
|||
overlayRef,
|
||||
panelHeight: sidebarHeight,
|
||||
handleVerticalDragStart,
|
||||
} = useSwipeToDismiss(() => setSideBarVisible(false));
|
||||
} = useSwipeToDismiss(
|
||||
() => setSideBarVisible(false),
|
||||
(data) => setIsFullHeightInMobile(data.clientY < 44),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSideBarVisible) {
|
||||
|
|
@ -191,9 +195,11 @@ const SideBar = ({}) => {
|
|||
width: isMobile ? '100%' : `${sideBarWidth}`,
|
||||
maxWidth: isMobile ? '100%' : `${MAX_SIDEBAR_WIDTH * 100}%`,
|
||||
position: isMobile ? 'fixed' : isSideBarPinned ? 'relative' : 'absolute',
|
||||
paddingTop: systemUIVisible
|
||||
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
|
||||
: `${safeAreaInsets?.top || 0}px`,
|
||||
paddingTop: isFullHeightInMobile
|
||||
? systemUIVisible
|
||||
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
|
||||
: `${safeAreaInsets?.top || 0}px`
|
||||
: '0px',
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
|
|
@ -229,7 +235,7 @@ const SideBar = ({}) => {
|
|||
aria-label={_('Resize Sidebar')}
|
||||
aria-orientation='vertical'
|
||||
aria-valuenow={sidebarHeight.current}
|
||||
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
|
||||
className='drag-handle flex h-6 max-h-6 min-h-6 w-full cursor-row-resize items-center justify-center'
|
||||
onMouseDown={handleVerticalDragStart}
|
||||
onTouchStart={handleVerticalDragStart}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle, Components } from 'react-virtuoso';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
|
@ -32,13 +34,6 @@ const computeExpandedSet = (toc: TOCItem[], href: string | undefined): Set<strin
|
|||
return new Set([...topLevel, ...parents]);
|
||||
};
|
||||
|
||||
const TOCScroller = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
(props, ref) => <div {...props} ref={ref} className='toc-scroller' />,
|
||||
);
|
||||
TOCScroller.displayName = 'TOCScroller';
|
||||
|
||||
const VIRTUOSO_COMPONENTS: Components = { Scroller: TOCScroller };
|
||||
|
||||
const TOCView: React.FC<{
|
||||
bookKey: string;
|
||||
toc: TOCItem[];
|
||||
|
|
@ -60,6 +55,33 @@ const TOCView: React.FC<{
|
|||
const pendingScrollRef = useRef(false);
|
||||
const visibleCenterRef = useRef(0);
|
||||
|
||||
// OverlayScrollbars + Virtuoso integration (same pattern as Bookshelf)
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
|
||||
setScroller(el instanceof HTMLElement ? el : null);
|
||||
}, []);
|
||||
|
||||
useTextTranslation(bookKey, containerRef.current, false, 'translation-target-toc');
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -141,38 +163,40 @@ const TOCView: React.FC<{
|
|||
virtuosoRef.current?.scrollToIndex({ index: idx, align: 'center', behavior });
|
||||
}
|
||||
pendingScrollRef.current = false;
|
||||
}, 100);
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [flatItems, activeHref, isSideBarVisible, isEink]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='toc-list rounded' role='tree'>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
components={VIRTUOSO_COMPONENTS}
|
||||
rangeChanged={({ startIndex, endIndex }) => {
|
||||
visibleCenterRef.current = Math.floor((startIndex + endIndex) / 2);
|
||||
}}
|
||||
onScroll={() => {
|
||||
userScrolledRef.current = true;
|
||||
if (scrollCooldownRef.current) clearTimeout(scrollCooldownRef.current);
|
||||
scrollCooldownRef.current = setTimeout(() => {
|
||||
userScrolledRef.current = false;
|
||||
}, 10000);
|
||||
}}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={flatItems.length}
|
||||
itemContent={(index) => (
|
||||
<StaticListRow
|
||||
bookKey={bookKey}
|
||||
flatItem={flatItems[index]!}
|
||||
activeHref={activeHref}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
)}
|
||||
overscan={500}
|
||||
/>
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' style={{ height: containerHeight }}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
scrollerRef={handleScrollerRef}
|
||||
rangeChanged={({ startIndex, endIndex }) => {
|
||||
visibleCenterRef.current = Math.floor((startIndex + endIndex) / 2);
|
||||
}}
|
||||
onScroll={() => {
|
||||
userScrolledRef.current = true;
|
||||
if (scrollCooldownRef.current) clearTimeout(scrollCooldownRef.current);
|
||||
scrollCooldownRef.current = setTimeout(() => {
|
||||
userScrolledRef.current = false;
|
||||
}, 10000);
|
||||
}}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={flatItems.length}
|
||||
itemContent={(index) => (
|
||||
<StaticListRow
|
||||
bookKey={bookKey}
|
||||
flatItem={flatItems[index]!}
|
||||
activeHref={activeHref}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
)}
|
||||
overscan={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,14 +17,26 @@ const useScrollToItem = (
|
|||
|
||||
const element = viewRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
|
||||
|
||||
// Find the actual scrollable container (OverlayScrollbars viewport)
|
||||
const scrollContainer = element.closest('[data-overlayscrollbars-viewport]');
|
||||
const containerRect = scrollContainer?.getBoundingClientRect();
|
||||
|
||||
const isVisible = containerRect
|
||||
? rect.top >= containerRect.top && rect.bottom <= containerRect.bottom
|
||||
: rect.top >= 0 && rect.bottom <= window.innerHeight;
|
||||
|
||||
if (!isVisible) {
|
||||
// Eink displays ghost previous frames during smooth JS scroll animations;
|
||||
// force an instant jump. scrollIntoView({ behavior: 'smooth' }) overrides
|
||||
// CSS scroll-behavior, so a CSS-only fix via useEinkMode is impossible.
|
||||
const isEink = document.documentElement.getAttribute('data-eink') === 'true';
|
||||
element.scrollIntoView({ behavior: isEink ? 'auto' : 'smooth', block: 'center' });
|
||||
|
||||
const containerCenter = containerRect
|
||||
? (containerRect.top + containerRect.bottom) / 2
|
||||
: window.innerHeight / 2;
|
||||
const distance = Math.abs(rect.top - containerCenter);
|
||||
const SMOOTH_THRESHOLD = 1000;
|
||||
const behavior = isEink || distance > SMOOTH_THRESHOLD ? 'auto' : 'smooth';
|
||||
|
||||
element.scrollIntoView({ behavior, block: 'center' });
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ const Dialog: React.FC<DialogProps> = ({
|
|||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-handle h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
|
||||
'drag-handle mb-2 h-6 max-h-6 min-h-6 w-full cursor-row-resize items-center justify-center',
|
||||
'transition-padding-top flex duration-300 ease-out sm:hidden',
|
||||
)}
|
||||
onMouseDown={handleDragStart}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export const useLongPress = (
|
|||
): UseLongPressResult => {
|
||||
const [pressing, setPressing] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const pressDelayRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerId = useRef<number | null>(null);
|
||||
const hasPointerEventsRef = useRef(false);
|
||||
|
|
@ -49,6 +50,10 @@ export const useLongPress = (
|
|||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
if (pressDelayRef.current) {
|
||||
clearTimeout(pressDelayRef.current);
|
||||
pressDelayRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
|
|
@ -65,7 +70,10 @@ export const useLongPress = (
|
|||
pointerId.current = e.pointerId;
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
isLongPressTriggered.current = false;
|
||||
setPressing(true);
|
||||
|
||||
pressDelayRef.current = setTimeout(() => {
|
||||
setPressing(true);
|
||||
}, 100);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (startPosRef.current) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export const usePullToRefresh = (
|
|||
const appr = createApprFunction(damping.MAX, damping.k);
|
||||
let isLoading = false;
|
||||
|
||||
// Disable native bounce on the scroll container so the JS-based
|
||||
// pull-to-refresh resistance is visible (especially on iOS WKWebView).
|
||||
el.style.overscrollBehavior = 'none';
|
||||
|
||||
el.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
|
||||
function handleTouchStart(startEvent: TouchEvent) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { useDrag } from '@/hooks/useDrag';
|
|||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
export const useSwipeToDismiss = (onDismiss: () => void) => {
|
||||
export const useSwipeToDismiss = (
|
||||
onDismiss: () => void,
|
||||
onDragMove?: (data: { clientY: number }) => void,
|
||||
) => {
|
||||
const { appService } = useEnv();
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -26,6 +29,8 @@ export const useSwipeToDismiss = (onDismiss: () => void) => {
|
|||
panel.style.transform = `translateY(${newTop * 100}%)`;
|
||||
overlay.style.opacity = `${1 - heightFraction}`;
|
||||
}
|
||||
|
||||
onDragMove?.(data);
|
||||
};
|
||||
|
||||
const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => {
|
||||
|
|
@ -52,6 +57,7 @@ export const useSwipeToDismiss = (onDismiss: () => void) => {
|
|||
panel.style.transform = 'translateY(0%)';
|
||||
overlay.style.transition = 'opacity 0.3s ease-out';
|
||||
overlay.style.opacity = '0.8';
|
||||
onDragMove?.({ clientY: 0 });
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -821,41 +821,3 @@ body.atmosphere #atmosphere-overlay {
|
|||
.animate-shake {
|
||||
animation: shake 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.library-scroller,
|
||||
.sidebar-scroller {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.library-scroller::-webkit-scrollbar,
|
||||
.sidebar-scroller::-webkit-scrollbar,
|
||||
.toc-scroller::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
.library-scroller::-webkit-scrollbar-track,
|
||||
.sidebar-scroller::-webkit-scrollbar-track,
|
||||
.toc-scroller::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.library-scroller::-webkit-scrollbar-thumb,
|
||||
.sidebar-scroller::-webkit-scrollbar-thumb,
|
||||
.toc-scroller::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.library-scroller:hover::-webkit-scrollbar-thumb,
|
||||
.sidebar-scroller:hover::-webkit-scrollbar-thumb,
|
||||
.toc-scroller:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.44);
|
||||
}
|
||||
.library-scroller:hover::-webkit-scrollbar-thumb:hover,
|
||||
.sidebar-scroller:hover::-webkit-scrollbar-thumb:hover,
|
||||
.toc-scroller:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.55);
|
||||
}
|
||||
.library-scroller:hover::-webkit-scrollbar-thumb:active,
|
||||
.sidebar-scroller:hover::-webkit-scrollbar-thumb:active,
|
||||
.toc-scroller:hover::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(var(--bc) / 0.66);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue