import clsx from 'clsx'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { impactFeedback } from '@tauri-apps/plugin-haptics'; 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 { DragKey, useDrag } from '@/hooks/useDrag'; 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 VELOCITY_THRESHOLD = 0.5; 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 sidebarHeight = useRef(1.0); 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 sidebarRef = useRef(null); const overlayRef = useRef(null); 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 handleVerticalDragMove = (data: { clientY: number }) => { if (!isMobile) return; const heightFraction = data.clientY / window.innerHeight; const newTop = Math.max(0.0, Math.min(1, heightFraction)); sidebarHeight.current = newTop; const sidebar = sidebarRef.current; const overlay = overlayRef.current; if (sidebar && overlay) { sidebar.style.transition = 'none'; sidebar.style.transform = `translateY(${newTop * 100}%)`; overlay.style.opacity = `${1 - heightFraction}`; } }; const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => { const sidebar = sidebarRef.current; const overlay = overlayRef.current; if (!sidebar || !overlay) return; if ( data.velocity > VELOCITY_THRESHOLD || (data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5) ) { const transitionDuration = 0.15 / Math.max(data.velocity, 0.5); sidebar.style.transition = `transform ${transitionDuration}s ease-out`; sidebar.style.transform = 'translateY(100%)'; overlay.style.transition = `opacity ${transitionDuration}s ease-out`; overlay.style.opacity = '0'; setTimeout(() => setSideBarVisible(false), 300); if (appService?.hasHaptics) { impactFeedback('medium'); } } else { sidebar.style.transition = 'transform 0.3s ease-out'; sidebar.style.transform = 'translateY(0%)'; overlay.style.transition = 'opacity 0.3s ease-out'; overlay.style.opacity = '0.8'; if (appService?.hasHaptics) { impactFeedback('medium'); } } }; const handleHorizontalDragMove = (data: { clientX: number }) => { const widthFraction = data.clientX / window.innerWidth; const newWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, widthFraction)); handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`); }; const handleHorizontalDragKeyDown = (data: { key: DragKey; step: number }) => { const currentWidth = parseFloat(getSideBarWidth()) / 100; let newWidth = currentWidth; if (data.key === 'ArrowLeft') { newWidth = Math.max(MIN_SIDEBAR_WIDTH, currentWidth - data.step); } else if (data.key === 'ArrowRight') { newWidth = Math.min(MAX_SIDEBAR_WIDTH, currentWidth + data.step); } handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`); }; const handleVerticalDragKeyDown = () => {}; const { handleDragStart: handleVerticalDragStart } = useDrag( handleVerticalDragMove, handleVerticalDragKeyDown, handleVerticalDragEnd, ); const { handleDragStart: handleHorizontalDragStart, handleDragKeyDown } = useDrag( handleHorizontalDragMove, handleHorizontalDragKeyDown, ); 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 && ( )}
{isMobile && (
)} setSideBarVisible(false)} onTogglePin={handleSideBarTogglePin} onToggleSearchBar={handleToggleSearchBar} />
{isSearchBarVisible && searchResults ? ( ) : ( )}
) : null; }; export default SideBar;