diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 47fefe39..d4bf547a 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -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 diff --git a/apps/readest-app/.claude/memory/virtuoso_overlayscrollbars.md b/apps/readest-app/.claude/memory/virtuoso_overlayscrollbars.md new file mode 100644 index 00000000..09246703 --- /dev/null +++ b/apps/readest-app/.claude/memory/virtuoso_overlayscrollbars.md @@ -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(null); +const [scroller, setScroller] = useState(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 +
+ +
+``` + +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: () =>
, +}; +``` + +## 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) diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 2df8585b..403d8ab3 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -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['List'] = React.forwardRef< HTMLDivElement, GridListProps & { context?: BookshelfListContext } @@ -118,11 +108,6 @@ const BookshelfGridList: GridComponents['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( ({ children, style, 'data-testid': testId }, ref) => (
@@ -134,9 +119,11 @@ BookshelfLinearList.displayName = 'BookshelfLinearList'; const GRID_VIRTUOSO_COMPONENTS: GridComponents = { List: BookshelfGridList, + Footer: () =>
, }; const LIST_VIRTUOSO_COMPONENTS: Components = { List: BookshelfLinearList, + Footer: () =>
, }; const Bookshelf: React.FC = ({ @@ -144,7 +131,7 @@ const Bookshelf: React.FC = ({ isSelectMode, isSelectAll, isSelectNone, - scrollParentEl, + onScrollerRef, handleImportBooks, handleBookUpload, handleBookDownload, @@ -475,6 +462,40 @@ const Bookshelf: React.FC = ({ }; }, []); + // OverlayScrollbars + Virtuoso integration: Virtuoso manages its own + // scroller; OverlayScrollbars wraps it for overlay scrollbar rendering. + const osRootRef = useRef(null); + const [scroller, setScroller] = useState(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 = ({ 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 && ( - - customScrollParent={scrollParentEl} - overscan={200} - totalCount={gridTotalCount} - components={GRID_VIRTUOSO_COMPONENTS} - context={listContext} - computeItemKey={computeItemKey} - itemContent={renderBookshelfItem} - /> - )} - {scrollParentEl && hasItems && !isGridMode && ( - - )} +
+ {hasItems && isGridMode && ( + + overscan={200} + totalCount={gridTotalCount} + components={GRID_VIRTUOSO_COMPONENTS} + context={listContext} + computeItemKey={computeItemKey} + itemContent={renderBookshelfItem} + scrollerRef={handleScrollerRef} + /> + )} + {hasItems && !isGridMode && ( + + )} +
{loading && (
diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 5112582f..551913ac 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -134,15 +134,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const viewSettings = settings.globalViewSettings; const demoBooks = useDemoBooks(); const scrollRef = useRef(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(null); - const attachScrollRef = useCallback((el: HTMLDivElement | null) => { + const handleScrollerRef = useCallback((el: HTMLDivElement | null) => { scrollRef.current = el; - setScrollEl(el); }, []); - const containerRef: React.MutableRefObject = useRef(null); + const containerRef = useRef(null); const pageRef = useRef(null); const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`; @@ -949,18 +944,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP )} {showBookshelf && (libraryBooks.some((book) => !book.deletedAt) ? ( -
+
@@ -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} diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx index 2a236e97..9168058f 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -51,14 +51,18 @@ const Notebook: React.FC = ({}) => { const [isSearchBarVisible, setIsSearchBarVisible] = useState(false); const [searchResults, setSearchResults] = useState(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', }} >