Files
readest/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx
T
Huang Xin b00d321817 refactor(reader): extract shared panel drag hooks and add vertical drag to Notebook (#3548)
Extract useSwipeToDismiss and usePanelResize hooks from duplicated code
in SideBar and Notebook. Add mobile swipe-to-dismiss drag handle to
Notebook matching SideBar's existing behavior. Fix drag cursor showing
col-resize instead of row-resize during vertical drags.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:27:27 +01:00

277 lines
9.3 KiB
TypeScript

import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { useEnv } from '@/context/EnvContext';
import { useSwipeToDismiss } from '@/hooks/useSwipeToDismiss';
import { usePanelResize } from '@/hooks/usePanelResize';
import { useThemeStore } from '@/store/themeStore';
import { Overlay } from '@/components/Overlay';
import useShortcuts from '@/hooks/useShortcuts';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
import useSidebar from '../../hooks/useSidebar';
import SearchBar from './SearchBar';
import SearchResults from './SearchResults';
const MIN_SIDEBAR_WIDTH = 0.05;
const MAX_SIDEBAR_WIDTH = 0.45;
const SideBar = ({}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { updateAppTheme, safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
const { sideBarBookKey, setSideBarBookKey, getSearchNavState, setSearchTerm, clearSearch } =
useSidebarStore();
const searchNavState = sideBarBookKey ? getSearchNavState(sideBarBookKey) : null;
const { searchTerm = '', searchResults = null } = searchNavState || {};
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const searchTermRef = useRef(searchTerm);
const isMobile = window.innerWidth < 640;
const {
sideBarWidth,
isSideBarPinned,
isSideBarVisible,
getSideBarWidth,
setSideBarVisible,
handleSideBarResize,
handleSideBarTogglePin,
} = useSidebar(
settings.globalReadSettings.sideBarWidth,
isMobile ? false : settings.globalReadSettings.isSideBarPinned,
);
const onSearchEvent = async (event: CustomEvent) => {
const { term, bookKey } = event.detail;
setSideBarVisible(true);
setSideBarBookKey(bookKey);
setIsSearchBarVisible(true);
if (term !== undefined && term !== null) {
setSearchTerm(bookKey, term);
}
};
const onNavigateEvent = async () => {
const pinButton = document.querySelector('.sidebar-pin-btn');
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
if (isPinButtonHidden) {
setSideBarVisible(false);
}
};
const {
panelRef: sidebarRef,
overlayRef,
panelHeight: sidebarHeight,
handleVerticalDragStart,
} = useSwipeToDismiss(() => setSideBarVisible(false));
useEffect(() => {
if (isSideBarVisible) {
updateAppTheme('base-200');
overlayRef.current = document.querySelector('.overlay') as HTMLDivElement | null;
} else {
updateAppTheme('base-100');
overlayRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible]);
useEffect(() => {
searchTermRef.current = searchTerm;
}, [searchTerm]);
useEffect(() => {
eventDispatcher.on('search-term', onSearchEvent);
eventDispatcher.on('navigate', onNavigateEvent);
return () => {
eventDispatcher.off('search-term', onSearchEvent);
eventDispatcher.off('navigate', onNavigateEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { handleResizeStart: handleHorizontalDragStart, handleResizeKeyDown: handleDragKeyDown } =
usePanelResize({
side: 'start',
minWidth: MIN_SIDEBAR_WIDTH,
maxWidth: MAX_SIDEBAR_WIDTH,
getWidth: getSideBarWidth,
onResize: handleSideBarResize,
});
const handleClickOverlay = () => {
setSideBarVisible(false);
};
const handleToggleSearchBar = () => {
setIsSearchBarVisible((prev) => {
if (prev) handleHideSearchBar();
return !prev;
});
};
const handleShowSearchBar = useCallback(() => {
setTimeout(() => {
setSideBarVisible(true);
setIsSearchBarVisible(true);
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleHideSearchBar = useCallback(() => {
setIsSearchBarVisible(false);
setTimeout(() => {
if (sideBarBookKey) clearSearch(sideBarBookKey);
}, 100);
getView(sideBarBookKey)?.clearSearch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey, clearSearch]);
const handleHideSideBar = useCallback(() => {
if (searchTermRef.current) {
handleHideSearchBar();
} else if (!isSideBarPinned) {
setSideBarVisible(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey, isSideBarPinned]);
useShortcuts({ onShowSearchBar: handleShowSearchBar, onEscape: handleHideSideBar }, [
handleHideSideBar,
]);
const handleSearchResultClick = (cfi: string) => {
onNavigateEvent();
getView(sideBarBookKey)?.goTo(cfi);
};
if (!sideBarBookKey) return null;
const viewSettings = getViewSettings(sideBarBookKey);
const bookData = getBookData(sideBarBookKey);
if (!bookData || !bookData.book || !bookData.bookDoc) {
return null;
}
const { book, bookDoc } = bookData;
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
return isSideBarVisible ? (
<>
{!isSideBarPinned && (
<Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={handleClickOverlay}
/>
)}
<div
ref={sidebarRef}
className={clsx(
'sidebar-container flex min-w-60 select-none flex-col',
'full-height transition-[padding-top] duration-300',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
isSideBarPinned ? 'z-20' : 'z-[45] shadow-2xl',
!isSideBarPinned && viewSettings?.isEink && 'border-base-content border-e',
)}
role='navigation'
aria-label={_('Sidebar')}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: isMobile ? '100%' : `${sideBarWidth}`,
maxWidth: isMobile ? '100%' : `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isMobile ? 'fixed' : isSideBarPinned ? 'relative' : 'absolute',
paddingTop: systemUIVisible
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@media (max-width: 640px) {
.sidebar-container {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.overlay {
transition: opacity 0.3s ease-in-out;
}
}
`}</style>
<div
className={clsx(
'drag-bar absolute -right-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-1',
isMobile && 'hidden',
)}
role='slider'
tabIndex={0}
aria-label={_('Resize Sidebar')}
aria-orientation='horizontal'
aria-valuenow={parseFloat(sideBarWidth)}
onMouseDown={handleHorizontalDragStart}
onTouchStart={handleHorizontalDragStart}
onKeyDown={handleDragKeyDown}
></div>
<div className='flex-shrink-0'>
{isMobile && (
<div
role='slider'
tabIndex={0}
aria-label={_('Resize Sidebar')}
aria-orientation='vertical'
aria-valuenow={sidebarHeight.current}
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleVerticalDragStart}
onTouchStart={handleVerticalDragStart}
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
)}
<SidebarHeader
bookKey={sideBarBookKey!}
isPinned={isSideBarPinned}
isSearchBarVisible={isSearchBarVisible}
onClose={() => setSideBarVisible(false)}
onTogglePin={handleSideBarTogglePin}
onToggleSearchBar={handleToggleSearchBar}
/>
<div
className={clsx('search-bar', {
'search-bar-visible': isSearchBarVisible,
})}
>
<SearchBar
isVisible={isSearchBarVisible}
bookKey={sideBarBookKey!}
onHideSearchBar={handleHideSearchBar}
/>
</div>
<div className='border-base-300/50 border-b px-3'>
<BookCard book={book} />
</div>
</div>
{isSearchBarVisible && searchResults ? (
<SearchResults
bookKey={sideBarBookKey!}
results={searchResults}
onSelectResult={handleSearchResultClick}
/>
) : (
<SidebarContent bookDoc={bookDoc} sideBarBookKey={sideBarBookKey!} />
)}
</div>
</>
) : null;
};
export default SideBar;