forked from akai/readest
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) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> => {
|
||||
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<string>, 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<string>(); // 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(['...']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<OverlayScrollbarsComponentRef>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
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());
|
||||
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) ? (
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
<div
|
||||
ref={scrollRef}
|
||||
aria-label={_('Your Bookshelf')}
|
||||
ref={osRef}
|
||||
className='flex-grow'
|
||||
options={{ scrollbars: { autoHide: 'scroll' } }}
|
||||
events={{
|
||||
initialized: (instance) => {
|
||||
const { content } = instance.elements();
|
||||
if (content) {
|
||||
containerRef.current = content as HTMLDivElement;
|
||||
}
|
||||
},
|
||||
}}
|
||||
className='library-scroller flex-grow'
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx('scroll-container drop-zone flex-grow', isDragging && 'drag-over')}
|
||||
style={{
|
||||
paddingTop: '0px',
|
||||
@@ -931,7 +914,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
|
||||
@@ -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' ? (
|
||||
<ChatHistoryView bookKey={sideBarBookKey} />
|
||||
) : (
|
||||
<OverlayScrollbarsComponent
|
||||
className='min-h-0 flex-1'
|
||||
options={{
|
||||
scrollbars: { autoHide: 'scroll', clickScroll: true },
|
||||
showNativeOverlaidScrollbars: false,
|
||||
}}
|
||||
defer
|
||||
>
|
||||
<div className='min-h-0 flex-1'>
|
||||
<div
|
||||
className={clsx(
|
||||
'scroll-container h-full transition-opacity duration-300 ease-in-out',
|
||||
@@ -92,16 +86,24 @@ const SidebarContent: React.FC<{
|
||||
)}
|
||||
>
|
||||
{targetTab === 'toc' && bookDoc.toc && (
|
||||
<TOCView toc={bookDoc.toc} sections={bookDoc.sections} bookKey={sideBarBookKey} />
|
||||
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
{targetTab === 'annotations' && (
|
||||
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
<div className='sidebar-scroller h-full'>
|
||||
<BooknoteView
|
||||
type='annotation'
|
||||
toc={bookDoc.toc ?? []}
|
||||
bookKey={sideBarBookKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{targetTab === 'bookmarks' && (
|
||||
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
<div className='sidebar-scroller h-full'>
|
||||
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback } from 'react';
|
||||
import { ListChildComponentProps } from 'react-window';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { getContentMd5 } from '@/utils/misc';
|
||||
|
||||
@@ -166,32 +165,3 @@ export const StaticListRow: React.FC<ListRowProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={style} title={flatItem.item.label || ''}>
|
||||
<StaticListRow
|
||||
bookKey={bookKey}
|
||||
flatItem={flatItem}
|
||||
itemSize={itemSize - 1}
|
||||
activeHref={activeHref}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string>) => {
|
||||
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<string>, 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<string> => {
|
||||
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<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[];
|
||||
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<Set<string>>(new Set());
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(() =>
|
||||
computeExpandedSet(toc, progress?.sectionHref),
|
||||
);
|
||||
const [containerHeight, setContainerHeight] = useState(400);
|
||||
|
||||
const hasInteractedWithTOCRef = useRef(false);
|
||||
const lastInteractionTimeRef = useRef<number>(0);
|
||||
const prevSideBarVisibleRef = useRef(false);
|
||||
const interactionCooldownMs = 10000;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const listOuterRef = useRef<HTMLDivElement | null>(null);
|
||||
const vitualListRef = useRef<VirtualList | null>(null);
|
||||
const staticListRef = useRef<HTMLDivElement | null>(null);
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
const userScrolledRef = useRef(false);
|
||||
const scrollCooldownRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLElement>(
|
||||
`[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 ? (
|
||||
<div
|
||||
className='virtual-list mt-2 rounded'
|
||||
data-overlayscrollbars-initialize=''
|
||||
role='tree'
|
||||
ref={containerRef}
|
||||
>
|
||||
<VirtualList
|
||||
ref={vitualListRef}
|
||||
outerRef={listOuterRef}
|
||||
width='100%'
|
||||
height={containerHeight}
|
||||
itemCount={flatItems.length}
|
||||
itemSize={virtualItemSize}
|
||||
itemData={virtualListData}
|
||||
overscanCount={20}
|
||||
initialScrollOffset={
|
||||
appService?.isAndroidApp && activeItemIndex >= 0
|
||||
? Math.max(0, activeItemIndex * virtualItemSize - containerHeight / 2)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{VirtualListRow}
|
||||
</VirtualList>
|
||||
</div>
|
||||
) : (
|
||||
<div className='static-list mt-2 rounded' role='tree' ref={staticListRef}>
|
||||
{flatItems.map((flatItem, index) => (
|
||||
<StaticListRow
|
||||
key={`static-row-${index}`}
|
||||
bookKey={bookKey}
|
||||
flatItem={flatItem}
|
||||
activeHref={activeHref}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const TabNavigation: React.FC<{
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bottom-tab border-base-300/50 bg-base-200/20 flex w-full border-t',
|
||||
'bottom-tab border-base-300/50 bg-base-200 flex w-full border-t',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
)}
|
||||
dir='ltr'
|
||||
|
||||
@@ -710,7 +710,13 @@ export class RSVPController extends EventTarget {
|
||||
};
|
||||
|
||||
walk(element);
|
||||
return words;
|
||||
|
||||
// Insert a blank ISI frame between consecutive identical words.
|
||||
return words.flatMap((word, i) =>
|
||||
i + 1 < words.length && word.text === words[i + 1]!.text
|
||||
? [word, { text: ' ', orpIndex: 0, pauseMultiplier: 0.5 }]
|
||||
: [word],
|
||||
);
|
||||
}
|
||||
|
||||
private calculateORP(word: string): number {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,7 +134,12 @@ export const useThemeStore = create<ThemeState>((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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: fe33bb5108...9a0c1c6f5b
Reference in New Issue
Block a user