From bfbe92f3555290a096649ab6df34b0413886f2de Mon Sep 17 00:00:00 2001 From: Lex Moulton Date: Wed, 8 Apr 2026 10:44:45 -0400 Subject: [PATCH] refactor(sidebar): replace react-window and OverlayScrollbars with react-virtuoso and CSS scrollbars (#3798) * feat: Add option to split words in RSVP mode * fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts * feat: Add option to split words in RSVP mode * fix(theme): update data-theme and themeCode when system theme changes * feat(rsvp): split on ellipsis between letters and preserve delimiter type * fix(rsvp): fix incorrect merged line * feat(rsvp): insert blank frame between consecutive identical words * refactor(sidebar): replace react-window and OverlayScrollbars with react-virtuoso and CSS scrollbars * feat(toc): smooth scroll to active chapter on sidebar open * test(theme-store): expand dark theme palette fixture with full color tokens * refactor: remove dead code and consolidate duplicate CSS scrollbar rules * fix(toc): fix auto-scroll on sidebar open and improve scroll behavior - Add isSideBarVisible to scroll effect deps so it fires on sidebar open - Use setTimeout delay for Virtuoso to finish layout before scrolling - Add 10s cooldown on user scroll to re-enable auto-scroll - Use smooth scroll for short distances (<16 items), instant for longer - Track visible center via rangeChanged for accurate distance calculation - Fix tab navigation background opacity (bg-base-200 instead of /20) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Huang Xin Co-authored-by: Claude Opus 4.6 (1M context) --- .../app/reader/toc-view-init.test.ts | 114 ++++++ .../services/rsvp-controller.test.ts | 59 +++ .../src/__tests__/services/rsvp-utils.test.ts | 12 + .../src/__tests__/store/theme-store.test.ts | 37 ++ apps/readest-app/src/app/library/page.tsx | 37 +- .../app/reader/components/sidebar/Content.tsx | 38 +- .../app/reader/components/sidebar/TOCItem.tsx | 30 -- .../app/reader/components/sidebar/TOCView.tsx | 356 +++++------------- .../components/sidebar/TabNavigation.tsx | 2 +- .../src/services/rsvp/RSVPController.ts | 8 +- apps/readest-app/src/services/rsvp/utils.ts | 14 +- apps/readest-app/src/store/themeStore.ts | 5 + apps/readest-app/src/styles/globals.css | 38 ++ packages/foliate-js | 2 +- 14 files changed, 418 insertions(+), 334 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/reader/toc-view-init.test.ts diff --git a/apps/readest-app/src/__tests__/app/reader/toc-view-init.test.ts b/apps/readest-app/src/__tests__/app/reader/toc-view-init.test.ts new file mode 100644 index 00000000..8936aed3 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/toc-view-init.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import type { TOCItem } from '@/libs/document'; + +/** + * Regression test for TOC sidebar blank on initial book load. + * + * When a book opens at a position with no TOC entry (e.g., cover page), + * progress.sectionHref is undefined and expandParents is never called. + * For books with a nested TOC structure (chapters nested under a root + * container), the sidebar appears blank because expandedItems is empty + * and only the root item is shown. + * + * Fix: Initialize expandedItems with top-level TOC items that have subitems + * so chapters are visible immediately on sidebar open. + */ + +// Mirrors the logic in TOCView.tsx's getItemIdentifier +const getItemIdentifier = (item: TOCItem) => { + const href = item.href || ''; + return `toc-item-${item.id}-${href}`; +}; + +// Mirrors the initialization logic to be added to TOCView.tsx +const getInitialExpandedItems = (toc: TOCItem[]): Set => { + const topLevelWithSubitems = toc + .filter((item) => item.subitems?.length) + .map((item) => getItemIdentifier(item)); + return topLevelWithSubitems.length > 0 ? new Set(topLevelWithSubitems) : new Set(); +}; + +// Mirrors useFlattenedTOC logic in TOCView.tsx +const flattenTOC = (items: TOCItem[], expandedItems: Set, depth = 0): TOCItem[] => { + const result: TOCItem[] = []; + items.forEach((item) => { + const isExpanded = expandedItems.has(getItemIdentifier(item)); + result.push(item); + if (item.subitems && isExpanded) { + result.push(...flattenTOC(item.subitems, expandedItems, depth + 1)); + } + }); + return result; +}; + +describe('TOC sidebar initialization', () => { + const nestedTOC: TOCItem[] = [ + { + id: 0, + label: 'Book', + href: undefined, + subitems: [ + { id: 1, label: 'Chapter 1', href: 'ch1.html' }, + { id: 2, label: 'Chapter 2', href: 'ch2.html' }, + { id: 3, label: 'Chapter 3', href: 'ch3.html' }, + ], + } as unknown as TOCItem, + ]; + + const flatTOC: TOCItem[] = [ + { id: 1, label: 'Chapter 1', href: 'ch1.html' } as unknown as TOCItem, + { id: 2, label: 'Chapter 2', href: 'ch2.html' } as unknown as TOCItem, + { id: 3, label: 'Chapter 3', href: 'ch3.html' } as unknown as TOCItem, + ]; + + describe('before fix (demonstrates the bug)', () => { + it('nested TOC with empty expandedItems only shows root item, not chapters', () => { + const expandedItems = new Set(); // Empty initial state (the bug) + const flatItems = flattenTOC(nestedTOC, expandedItems); + // Only root "Book" shows — chapters are hidden + expect(flatItems).toHaveLength(1); + expect(flatItems[0]!.label).toBe('Book'); + }); + }); + + describe('after fix (initialization effect behavior)', () => { + it('nested TOC: getInitialExpandedItems expands the root container', () => { + const expandedItems = getInitialExpandedItems(nestedTOC); + expect(expandedItems.size).toBe(1); + expect(expandedItems.has(getItemIdentifier(nestedTOC[0]!))).toBe(true); + }); + + it('nested TOC: with initialized expandedItems, all chapters are visible', () => { + const expandedItems = getInitialExpandedItems(nestedTOC); + const flatItems = flattenTOC(nestedTOC, expandedItems); + // Root + 3 chapters = 4 items + expect(flatItems).toHaveLength(4); + expect(flatItems[1]!.label).toBe('Chapter 1'); + expect(flatItems[2]!.label).toBe('Chapter 2'); + expect(flatItems[3]!.label).toBe('Chapter 3'); + }); + + it('flat TOC: getInitialExpandedItems returns empty set (no change)', () => { + const expandedItems = getInitialExpandedItems(flatTOC); + expect(expandedItems.size).toBe(0); + }); + + it('flat TOC: all chapters visible regardless of expandedItems', () => { + const expandedItems = getInitialExpandedItems(flatTOC); + const flatItems = flattenTOC(flatTOC, expandedItems); + expect(flatItems).toHaveLength(3); + }); + + it('non-empty expandedItems is preserved (no re-initialization)', () => { + const existingItems = new Set(['toc-item-1-ch1.html']); + // The effect uses: if (prev.size > 0) return prev + const result = existingItems.size > 0 ? existingItems : getInitialExpandedItems(nestedTOC); + expect(result).toBe(existingItems); // Same reference - not re-initialized + }); + + it('empty TOC produces empty expandedItems', () => { + const expandedItems = getInitialExpandedItems([]); + expect(expandedItems.size).toBe(0); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts index f56171f6..761b2a4a 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -84,4 +84,63 @@ describe('RSVPController', () => { expect(controller.currentState.words[0]!.text).toBe('Foo'); }); }); + + describe('currentDisplayWord', () => { + test('returns full word when splitHyphens is false', () => { + const doc = makeDoc('well-known'); + const view = createMockView(0, [doc]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.setSplitHyphens(false); + controller.start(); + + expect(controller.currentDisplayWord?.text).toBe('well-known'); + }); + + test('returns first part only when splitHyphens is true', () => { + const doc = makeDoc('well-known'); + const view = createMockView(0, [doc]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.setSplitHyphens(true); + controller.start(); + + expect(controller.currentDisplayWord?.text).toBe('well-'); + }); + + test('returns unsplit word when splitHyphens is true but no hyphen pattern', () => { + const doc = makeDoc('hello'); + const view = createMockView(0, [doc]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.setSplitHyphens(true); + controller.start(); + + expect(controller.currentDisplayWord?.text).toBe('hello'); + }); + }); + + describe('duplicate word blank insertion', () => { + test('inserts blank between two consecutive identical words', () => { + const doc = makeDoc('the the cat'); + const view = createMockView(0, [doc]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.start(); + + const words = controller.currentState.words; + expect(words[0]!.text).toBe('the'); + expect(words[1]!.text).toBe(' '); + expect(words[2]!.text).toBe('the'); + expect(words[3]!.text).toBe('cat'); + }); + + test('does not insert blank between different words', () => { + const doc = makeDoc('the cat'); + const view = createMockView(0, [doc]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.start(); + + const words = controller.currentState.words; + expect(words.length).toBe(2); + expect(words[0]!.text).toBe('the'); + expect(words[1]!.text).toBe('cat'); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts index 69aea01b..c408505f 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts @@ -274,5 +274,17 @@ describe('rsvp/utils', () => { test('returns trailing-hyphen word unchanged', () => { expect(getHyphenParts('word-')).toEqual(['word-']); }); + + test('splits on ellipsis between letters with trailing ellipsis on non-last parts', () => { + expect(getHyphenParts('a...b')).toEqual(['a...', 'b']); + }); + + test('splits mixed hyphens and ellipses preserving each delimiter', () => { + expect(getHyphenParts('foo-bar...baz')).toEqual(['foo-', 'bar...', 'baz']); + }); + + test('returns ellipsis-only unchanged', () => { + expect(getHyphenParts('...')).toEqual(['...']); + }); }); }); diff --git a/apps/readest-app/src/__tests__/store/theme-store.test.ts b/apps/readest-app/src/__tests__/store/theme-store.test.ts index 302c4453..6e7cf4ed 100644 --- a/apps/readest-app/src/__tests__/store/theme-store.test.ts +++ b/apps/readest-app/src/__tests__/store/theme-store.test.ts @@ -149,6 +149,43 @@ describe('themeStore', () => { expect(state.systemIsDarkMode).toBe(true); expect(state.isDarkMode).toBe(false); }); + + test('updates themeCode when system theme changes to dark', async () => { + const styleModule = await import('@/utils/style'); + const mockGetThemeCode = vi.mocked(styleModule.getThemeCode); + const darkThemeCode = { + bg: '#1a1a1a', + fg: '#ffffff', + primary: '#4d9fff', + palette: { + 'base-100': '#1a1a1a', + 'base-200': '#2a2a2a', + 'base-300': '#3a3a3a', + 'base-content': '#ffffff', + neutral: '#333333', + 'neutral-content': '#ffffff', + primary: '#4d9fff', + secondary: '#6c757d', + accent: '#4d9fff', + }, + isDarkMode: true, + }; + mockGetThemeCode.mockReturnValueOnce(darkThemeCode); + + useThemeStore.setState({ themeMode: 'auto' }); + useThemeStore.getState().handleSystemThemeChange(true); + + expect(useThemeStore.getState().themeCode).toEqual(darkThemeCode); + }); + + test('updates data-theme attribute when system theme changes', () => { + useThemeStore.setState({ themeMode: 'auto', themeColor: 'default' }); + useThemeStore.getState().handleSystemThemeChange(true); + expect(document.documentElement.getAttribute('data-theme')).toBe('default-dark'); + + useThemeStore.getState().handleSystemThemeChange(false); + expect(document.documentElement.getAttribute('data-theme')).toBe('default-light'); + }); }); describe('showSystemUI / dismissSystemUI', () => { diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 64192fef..bdaf0212 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -5,8 +5,6 @@ import * as React from 'react'; import { MdChevronRight } from 'react-icons/md'; import { useState, useRef, useEffect, Suspense, useCallback } from 'react'; import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation'; -import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; -import 'overlayscrollbars/overlayscrollbars.css'; import { Book } from '@/types/book'; import { AppService, DeleteAction } from '@/types/system'; @@ -134,28 +132,22 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const iconSize = useResponsiveSize(18); const viewSettings = settings.globalViewSettings; const demoBooks = useDemoBooks(); - const osRef = useRef(null); + const scrollRef = useRef(null); const containerRef: React.MutableRefObject = useRef(null); const pageRef = useRef(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()); + if (scrollRef.current) { + sessionStorage.setItem(getScrollKey(group), scrollRef.current.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; - } + if (savedPosition && scrollRef.current) { + scrollRef.current.scrollTop = parseInt(savedPosition, 10); } }, []); @@ -890,22 +882,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP )} {showBookshelf && (libraryBooks.some((book) => !book.deletedAt) ? ( - { - const { content } = instance.elements(); - if (content) { - containerRef.current = content as HTMLDivElement; - } - }, - }} + className='library-scroller flex-grow' >
-
+ ) : (
diff --git a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx index 92283558..5e791cb1 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx @@ -6,8 +6,6 @@ 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'; @@ -45,13 +43,16 @@ const SidebarContent: React.FC<{ }, [aiEnabled, activeTab, targetTab]); const handleTabChange = (tab: string) => { - setFade(true); - const timeout = setTimeout(() => { - if (activeTab === tab && isMobile) { + if (activeTab === tab) { + if (isMobile) { setHoveredBookKey(sideBarBookKey); setSideBarVisible(false); - return; } + return; + } + + setFade(true); + const timeout = setTimeout(() => { setTargetTab(tab); setFade(false); setConfig(sideBarBookKey!, config); @@ -74,14 +75,7 @@ const SidebarContent: React.FC<{ {targetTab === 'history' ? ( ) : ( - +
{targetTab === 'toc' && bookDoc.toc && ( - + )} {targetTab === 'annotations' && ( - +
+ +
)} {targetTab === 'bookmarks' && ( - +
+ +
)}
- +
)}
= ({
); }; - -export const VirtualListRow: React.FC< - ListChildComponentProps & { - data: { - bookKey: string; - flatItems: FlatTOCItem[]; - itemSize: number; - activeHref: string | null; - onToggleExpand: (item: TOCItem) => void; - onItemClick: (item: TOCItem) => void; - }; - } -> = ({ index, style, data }) => { - const { flatItems, bookKey, activeHref, itemSize, onToggleExpand, onItemClick } = data; - const flatItem = flatItems[index]; - - return ( -
- -
- ); -}; diff --git a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx index e16ed890..92d96735 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx @@ -1,117 +1,65 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FixedSizeList as VirtualList } from 'react-window'; +import { Virtuoso, VirtuosoHandle, Components } from 'react-virtuoso'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import 'overlayscrollbars/overlayscrollbars.css'; -import { SectionItem, TOCItem } from '@/libs/document'; -import { useEnv } from '@/context/EnvContext'; +import { TOCItem } from '@/libs/document'; import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { findParentPath } from '@/utils/toc'; import { eventDispatcher } from '@/utils/event'; -import { getContentMd5 } from '@/utils/misc'; import { useTextTranslation } from '../../hooks/useTextTranslation'; -import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem'; +import { FlatTOCItem, StaticListRow } from './TOCItem'; const getItemIdentifier = (item: TOCItem) => { const href = item.href || ''; return `toc-item-${item.id}-${href}`; }; -const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set) => { - return useMemo(() => { - const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => { - const result: FlatTOCItem[] = []; - items.forEach((item, index) => { - const isExpanded = expandedItems.has(getItemIdentifier(item)); - result.push({ item, depth, index, isExpanded }); - if (item.subitems && isExpanded) { - result.push(...flattenTOC(item.subitems, depth + 1)); - } - }); - return result; - }; - - return flattenTOC(toc); - }, [toc, expandedItems]); +const flattenTOC = (items: TOCItem[], expandedItems: Set, depth = 0): FlatTOCItem[] => { + const result: FlatTOCItem[] = []; + items.forEach((item, index) => { + const isExpanded = expandedItems.has(getItemIdentifier(item)); + result.push({ item, depth, index, isExpanded }); + if (item.subitems && isExpanded) { + result.push(...flattenTOC(item.subitems, expandedItems, depth + 1)); + } + }); + return result; }; +const computeExpandedSet = (toc: TOCItem[], href: string | undefined): Set => { + const topLevel = toc.filter((item) => item.subitems?.length).map(getItemIdentifier); + const parents = href ? findParentPath(toc, href).map(getItemIdentifier).filter(Boolean) : []; + return new Set([...topLevel, ...parents]); +}; + +const TOCScroller = React.forwardRef>( + (props, ref) =>
, +); +TOCScroller.displayName = 'TOCScroller'; + +const VIRTUOSO_COMPONENTS: Components = { Scroller: TOCScroller }; + const TOCView: React.FC<{ bookKey: string; toc: TOCItem[]; - sections?: SectionItem[]; -}> = ({ bookKey, toc, sections }) => { - const { appService } = useEnv(); - const { getView, getProgress, getViewSettings } = useReaderStore(); +}> = ({ bookKey, toc }) => { + const { getView, getProgress } = useReaderStore(); const { sideBarBookKey, isSideBarVisible } = useSidebarStore(); - const viewSettings = getViewSettings(bookKey)!; const progress = getProgress(bookKey); - const [expandedItems, setExpandedItems] = useState>(new Set()); + const [expandedItems, setExpandedItems] = useState>(() => + computeExpandedSet(toc, progress?.sectionHref), + ); const [containerHeight, setContainerHeight] = useState(400); - const hasInteractedWithTOCRef = useRef(false); - const lastInteractionTimeRef = useRef(0); - const prevSideBarVisibleRef = useRef(false); - const interactionCooldownMs = 10000; const containerRef = useRef(null); - const listOuterRef = useRef(null); - const vitualListRef = useRef(null); - const staticListRef = useRef(null); + const virtuosoRef = useRef(null); + const userScrolledRef = useRef(false); + const scrollCooldownRef = useRef | null>(null); + const pendingScrollRef = useRef(false); + const visibleCenterRef = useRef(0); - const [initialize] = useOverlayScrollbars({ - defer: true, - options: { - scrollbars: { - autoHide: 'scroll', - }, - showNativeOverlaidScrollbars: false, - }, - events: { - initialized(osInstance) { - const { viewport } = osInstance.elements(); - viewport.style.overflowX = `var(--os-viewport-overflow-x)`; - viewport.style.overflowY = `var(--os-viewport-overflow-y)`; - }, - }, - }); - - const isInCooldown = useCallback(() => { - if (!hasInteractedWithTOCRef.current) return false; - return Date.now() - lastInteractionTimeRef.current < interactionCooldownMs; - }, []); - - const handleInteraction = useCallback(() => { - hasInteractedWithTOCRef.current = true; - lastInteractionTimeRef.current = Date.now(); - }, []); - - useEffect(() => { - const { current: root } = containerRef; - const { current: virtualOuter } = listOuterRef; - - if (root && virtualOuter) { - initialize({ - target: root, - elements: { - viewport: virtualOuter, - }, - }); - - virtualOuter.addEventListener('scroll', handleInteraction); - return () => { - virtualOuter.removeEventListener('scroll', handleInteraction); - }; - } - return; - }, [initialize, handleInteraction]); - - useTextTranslation( - bookKey, - containerRef.current || staticListRef.current, - false, - 'translation-target-toc', - ); + useTextTranslation(bookKey, containerRef.current, false, 'translation-target-toc'); useEffect(() => { const updateHeight = () => { @@ -135,50 +83,27 @@ const TOCView: React.FC<{ resizeObserver.observe(parentContainer); } } - - const staticList = staticListRef.current; - let scrollContainer: Element | null = null; - - if (staticList) { - scrollContainer = staticList.parentElement; - if (scrollContainer) { - scrollContainer.addEventListener('scroll', handleInteraction); - } - } - return () => { window.removeEventListener('resize', updateHeight); - if (resizeObserver) { - resizeObserver.disconnect(); - } - if (scrollContainer) { - scrollContainer.removeEventListener('scroll', handleInteraction); - } + if (resizeObserver) resizeObserver.disconnect(); }; - }, [expandedItems, handleInteraction]); + }, []); - const activeHref = useMemo(() => progress?.sectionHref || null, [progress?.sectionHref]); - const flatItems = useFlattenedTOC(toc, expandedItems); - const activeItemIndex = useMemo(() => { - return flatItems.findIndex((item) => item.item.href === activeHref); - }, [flatItems, activeHref]); + const activeHref = progress?.sectionHref ?? null; + const flatItems = useMemo(() => flattenTOC(toc, expandedItems), [toc, expandedItems]); - const handleToggleExpand = useCallback( - (item: TOCItem) => { - const itemId = getItemIdentifier(item); - handleInteraction(); - setExpandedItems((prev) => { - const newSet = new Set(prev); - if (newSet.has(itemId)) { - newSet.delete(itemId); - } else { - newSet.add(itemId); - } - return newSet; - }); - }, - [handleInteraction], - ); + const handleToggleExpand = useCallback((item: TOCItem) => { + const itemId = getItemIdentifier(item); + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }, []); const handleItemClick = useCallback( (item: TOCItem) => { @@ -190,134 +115,59 @@ const TOCView: React.FC<{ [bookKey, getView], ); - const expandParents = useCallback((toc: TOCItem[], href: string) => { - const parentItems = findParentPath(toc, href) - .map((item) => getItemIdentifier(item)) - .filter(Boolean); - setExpandedItems(new Set(parentItems)); - }, []); + useEffect(() => { + if (!isSideBarVisible || sideBarBookKey !== bookKey) { + userScrolledRef.current = false; + pendingScrollRef.current = false; + return; + } + if (userScrolledRef.current) return; + setExpandedItems(computeExpandedSet(toc, progress?.sectionHref)); + if (progress?.sectionHref) pendingScrollRef.current = true; + }, [isSideBarVisible, sideBarBookKey, bookKey, toc, progress]); - const scrollToActiveItem = useCallback( - (shouldFocus = false) => { - if (!activeHref) return; - - if (vitualListRef.current) { - const activeIndex = flatItems.findIndex((flatItem) => flatItem.item.href === activeHref); - if (activeIndex !== -1) { - vitualListRef.current.scrollToItem(activeIndex, 'center'); - } + useEffect(() => { + if (!pendingScrollRef.current || !activeHref || !isSideBarVisible) return; + const timer = setTimeout(() => { + const idx = flatItems.findIndex((f) => f.item.href === activeHref); + if (idx !== -1) { + const distance = Math.abs(idx - visibleCenterRef.current); + const behavior = distance > 16 ? 'auto' : 'smooth'; + virtuosoRef.current?.scrollToIndex({ index: idx, align: 'center', behavior }); } + pendingScrollRef.current = false; + }, 100); + return () => clearTimeout(timer); + }, [flatItems, activeHref, isSideBarVisible]); - if (staticListRef.current) { - const hrefMd5 = activeHref ? getContentMd5(activeHref) : ''; - const activeItem = staticListRef.current?.querySelector( - `[data-href="${hrefMd5}"]`, - ); - if (activeItem) { - const container = staticListRef.current.parentElement!; - const containerRect = container.getBoundingClientRect(); - const itemRect = activeItem.getBoundingClientRect(); - const isVisible = - itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom; - if (!isVisible) { - activeItem.scrollIntoView({ behavior: 'instant', block: 'center' }); - } - if (shouldFocus) { - activeItem.focus({ preventScroll: true }); - } - } - } - }, - [flatItems, activeHref], - ); - - const virtualItemSize = useMemo(() => { - return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57; - }, [viewSettings?.translationEnabled]); - - const virtualListData = useMemo( - () => ({ - flatItems, - itemSize: virtualItemSize, - bookKey, - activeHref, - onToggleExpand: handleToggleExpand, - onItemClick: handleItemClick, - }), - [flatItems, virtualItemSize, bookKey, activeHref, handleToggleExpand, handleItemClick], - ); - - useEffect(() => { - if (!progress) return; - if (!isSideBarVisible) return; - if (sideBarBookKey !== bookKey) return; - if (isInCooldown()) return; - hasInteractedWithTOCRef.current = false; - - const { sectionHref: currentHref } = progress; - if (currentHref) { - expandParents(toc, currentHref); - } - }, [toc, progress, sideBarBookKey, isSideBarVisible, bookKey, expandParents, isInCooldown]); - - useEffect(() => { - if (isInCooldown()) return; - hasInteractedWithTOCRef.current = false; - - if (flatItems.length > 0) { - setTimeout(scrollToActiveItem, appService?.isAndroidApp ? 300 : 100); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [progress, scrollToActiveItem, isInCooldown]); - - useEffect(() => { - const wasVisible = prevSideBarVisibleRef.current; - prevSideBarVisibleRef.current = isSideBarVisible; - - if (isSideBarVisible && !wasVisible && sideBarBookKey === bookKey) { - setTimeout(() => scrollToActiveItem(true), appService?.isAndroidApp ? 400 : 200); - } - }, [isSideBarVisible, sideBarBookKey, bookKey, scrollToActiveItem, appService]); - - const useVirtualization = sections && sections.length > 256; - - return useVirtualization ? ( -
- = 0 - ? Math.max(0, activeItemIndex * virtualItemSize - containerHeight / 2) - : undefined - } - > - {VirtualListRow} - -
- ) : ( -
- {flatItems.map((flatItem, index) => ( - - ))} + return ( +
+ { + 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) => ( + + )} + overscan={500} + />
); }; diff --git a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx index 3e6c3aa2..604cb473 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx @@ -38,7 +38,7 @@ const TabNavigation: React.FC<{ return (
+ i + 1 < words.length && word.text === words[i + 1]!.text + ? [word, { text: ' ', orpIndex: 0, pauseMultiplier: 0.5 }] + : [word], + ); } private calculateORP(word: string): number { diff --git a/apps/readest-app/src/services/rsvp/utils.ts b/apps/readest-app/src/services/rsvp/utils.ts index b8e5003d..0349ce84 100644 --- a/apps/readest-app/src/services/rsvp/utils.ts +++ b/apps/readest-app/src/services/rsvp/utils.ts @@ -180,9 +180,17 @@ export function segmentCJKText(text: string): string[] { * "hello" → ["hello"] */ export function getHyphenParts(word: string): string[] { - if (!/[a-zA-Z]-[a-zA-Z]/.test(word)) return [word]; - const parts = word.split(/-(?=[a-zA-Z])/); - return parts.map((part, i) => (i < parts.length - 1 ? part + '-' : part)); + if (!/[a-zA-Z](?:-|\.\.\.)[a-zA-Z]/.test(word)) return [word]; + // Capturing group preserves the delimiter in the split result array + const parts = word.split(/([-]|\.\.\.)(?=[a-zA-Z])/); + // parts = ["foo", "-", "bar", "...", "baz"] for "foo-bar...baz" + const result: string[] = []; + for (let i = 0; i < parts.length; i += 2) { + const segment = parts[i]!; + const delimiter = parts[i + 1]; + result.push(delimiter ? segment + delimiter : segment); + } + return result; } /** diff --git a/apps/readest-app/src/store/themeStore.ts b/apps/readest-app/src/store/themeStore.ts index a61d67c5..5a013fad 100644 --- a/apps/readest-app/src/store/themeStore.ts +++ b/apps/readest-app/src/store/themeStore.ts @@ -134,7 +134,12 @@ export const useThemeStore = create((set, get) => { handleSystemThemeChange: (systemIsDarkMode) => { const mode = get().themeMode; const isDarkMode = mode === 'dark' || (mode === 'auto' && systemIsDarkMode); + document.documentElement.setAttribute( + 'data-theme', + `${get().themeColor}-${isDarkMode ? 'dark' : 'light'}`, + ); set({ systemIsDarkMode, isDarkMode }); + set({ themeCode: getThemeCode() }); }, updateSafeAreaInsets: (insets) => { set({ safeAreaInsets: insets }); diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 0d799a1a..e3fbadfb 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -821,3 +821,41 @@ 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); +} diff --git a/packages/foliate-js b/packages/foliate-js index fe33bb51..9a0c1c6f 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit fe33bb510871bd0489493c49638a5b82c1de5df5 +Subproject commit 9a0c1c6f5bcb3a16b659d4ee4c4ceb437170fda9