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:
Lex Moulton
2026-04-08 10:44:45 -04:00
committed by GitHub
parent 799db40763
commit bfbe92f355
14 changed files with 418 additions and 334 deletions
@@ -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', () => {
+10 -27
View File
@@ -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 {
+11 -3
View File
@@ -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;
}
/**
+5
View File
@@ -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 });
+38
View File
@@ -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);
}