From b00d32181799eee23f27ebc7e59ab02cc5f9bf4a Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 17 Mar 2026 11:27:27 +0800 Subject: [PATCH] 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) --- .../reader/components/notebook/Notebook.tsx | 70 ++++++++----- .../app/reader/components/sidebar/SideBar.tsx | 99 +++---------------- apps/readest-app/src/hooks/useDrag.ts | 5 +- apps/readest-app/src/hooks/usePanelResize.ts | 55 +++++++++++ .../src/hooks/useSwipeToDismiss.ts | 71 +++++++++++++ 5 files changed, 193 insertions(+), 107 deletions(-) create mode 100644 apps/readest-app/src/hooks/usePanelResize.ts create mode 100644 apps/readest-app/src/hooks/useSwipeToDismiss.ts diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx index 1228e42c..2a236e97 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -10,7 +10,8 @@ import { useAIChatStore } from '@/store/aiChatStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useThemeStore } from '@/store/themeStore'; import { useEnv } from '@/context/EnvContext'; -import { DragKey, useDrag } from '@/hooks/useDrag'; +import { useSwipeToDismiss } from '@/hooks/useSwipeToDismiss'; +import { usePanelResize } from '@/hooks/usePanelResize'; import { TextSelection } from '@/utils/sel'; import { BookNote } from '@/types/book'; import { uniqueId } from '@/utils/misc'; @@ -51,6 +52,14 @@ const Notebook: React.FC = ({}) => { const [searchResults, setSearchResults] = useState(null); const [searchTerm, setSearchTerm] = useState(''); + const { + panelRef: notebookRef, + overlayRef, + panelHeight: notebookHeight, + handleVerticalDragStart, + } = useSwipeToDismiss(() => setNotebookVisible(false)); + const isMobile = window.innerWidth < 640; + const onNavigateEvent = async () => { const pinButton = document.querySelector('.sidebar-pin-btn'); const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none'; @@ -71,8 +80,10 @@ const Notebook: React.FC = ({}) => { useEffect(() => { if (isNotebookVisible) { 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 }, [isNotebookVisible]); @@ -176,25 +187,14 @@ const Notebook: React.FC = ({}) => { setNotebookEditAnnotation(null); }; - const onDragMove = (data: { clientX: number }) => { - const widthFraction = 1 - data.clientX / window.innerWidth; - const newWidth = Math.max(MIN_NOTEBOOK_WIDTH, Math.min(MAX_NOTEBOOK_WIDTH, widthFraction)); - handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`); - }; - - const onDragKeyDown = (data: { key: DragKey; step: number }) => { - const currentWidth = parseFloat(getNotebookWidth()) / 100; - let newWidth = currentWidth; - - if (data.key === 'ArrowLeft') { - newWidth = Math.max(MIN_NOTEBOOK_WIDTH, currentWidth + data.step); - } else if (data.key === 'ArrowRight') { - newWidth = Math.min(MAX_NOTEBOOK_WIDTH, currentWidth - data.step); - } - handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`); - }; - - const { handleDragStart, handleDragKeyDown } = useDrag(onDragMove, onDragKeyDown); + const { handleResizeStart: handleDragStart, handleResizeKeyDown: handleDragKeyDown } = + usePanelResize({ + side: 'end', + minWidth: MIN_NOTEBOOK_WIDTH, + maxWidth: MAX_NOTEBOOK_WIDTH, + getWidth: getNotebookWidth, + onResize: handleNotebookResize, + }); const config = getConfig(sideBarBookKey); const { booknotes: allNotes = [] } = config || {}; @@ -241,17 +241,17 @@ const Notebook: React.FC = ({}) => { const hasSearchResults = filteredAnnotationNotes.length > 0 || filteredExcerptNotes.length > 0; const hasAnyNotes = annotationNotes.length > 0 || excerptNotes.length > 0; - const isMobile = window.innerWidth < 640; return isNotebookVisible ? ( <> {!isNotebookPinned && ( )}
{ : `${safeAreaInsets?.top || 0}px`, }} > +
{ onKeyDown={handleDragKeyDown} />
+ {isMobile && ( +
+
+
+ )} { const _ = useTranslation(); const { appService } = useEnv(); @@ -39,7 +37,6 @@ const SideBar = ({}) => { 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, @@ -72,8 +69,12 @@ const SideBar = ({}) => { } }; - const sidebarRef = useRef(null); - const overlayRef = useRef(null); + const { + panelRef: sidebarRef, + overlayRef, + panelHeight: sidebarHeight, + handleVerticalDragStart, + } = useSwipeToDismiss(() => setSideBarVisible(false)); useEffect(() => { if (isSideBarVisible) { @@ -100,82 +101,14 @@ const SideBar = ({}) => { // 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 { handleResizeStart: handleHorizontalDragStart, handleResizeKeyDown: handleDragKeyDown } = + usePanelResize({ + side: 'start', + minWidth: MIN_SIDEBAR_WIDTH, + maxWidth: MAX_SIDEBAR_WIDTH, + getWidth: getSideBarWidth, + onResize: handleSideBarResize, + }); const handleClickOverlay = () => { setSideBarVisible(false); diff --git a/apps/readest-app/src/hooks/useDrag.ts b/apps/readest-app/src/hooks/useDrag.ts index 5a2f0bc9..f6f3c35f 100644 --- a/apps/readest-app/src/hooks/useDrag.ts +++ b/apps/readest-app/src/hooks/useDrag.ts @@ -13,6 +13,7 @@ export const useDrag = ( deltaX: number; deltaY: number; }) => void, + cursor: string = 'col-resize', ) => { const isDragging = useRef(false); const startX = useRef(0); @@ -36,7 +37,7 @@ export const useDrag = ( document.body.style.pointerEvents = 'none'; document.body.style.userSelect = 'none'; - document.documentElement.style.cursor = 'col-resize'; + document.documentElement.style.cursor = cursor; const handleMove = (event: MouseEvent | TouchEvent) => { if (isDragging.current) { @@ -105,7 +106,7 @@ export const useDrag = ( window.addEventListener('touchmove', handleMove, { passive: true }); window.addEventListener('touchend', handleEnd); }, - [onDragMove, onDragEnd], + [onDragMove, onDragEnd, cursor], ); const handleDragKeyDown = useCallback( diff --git a/apps/readest-app/src/hooks/usePanelResize.ts b/apps/readest-app/src/hooks/usePanelResize.ts new file mode 100644 index 00000000..b51f3ede --- /dev/null +++ b/apps/readest-app/src/hooks/usePanelResize.ts @@ -0,0 +1,55 @@ +import { DragKey, useDrag } from '@/hooks/useDrag'; + +interface PanelResizeOptions { + side: 'start' | 'end'; + minWidth: number; + maxWidth: number; + getWidth: () => string; + onResize: (width: string) => void; +} + +export const usePanelResize = ({ + side, + minWidth, + maxWidth, + getWidth, + onResize, +}: PanelResizeOptions) => { + const toPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`; + + const isPhysicallyLeft = () => { + const isRtl = getComputedStyle(document.documentElement).direction === 'rtl'; + return side === 'start' ? !isRtl : isRtl; + }; + + const handleDragMove = (data: { clientX: number }) => { + const fraction = isPhysicallyLeft() + ? data.clientX / window.innerWidth + : 1 - data.clientX / window.innerWidth; + const newWidth = Math.max(minWidth, Math.min(maxWidth, fraction)); + onResize(toPercent(newWidth)); + }; + + const handleDragKeyDown = (data: { key: DragKey; step: number }) => { + const currentWidth = parseFloat(getWidth()) / 100; + let newWidth = currentWidth; + + const left = isPhysicallyLeft(); + const growKey: DragKey = left ? 'ArrowRight' : 'ArrowLeft'; + const shrinkKey: DragKey = left ? 'ArrowLeft' : 'ArrowRight'; + + if (data.key === growKey) { + newWidth = Math.min(maxWidth, currentWidth + data.step); + } else if (data.key === shrinkKey) { + newWidth = Math.max(minWidth, currentWidth - data.step); + } + onResize(toPercent(newWidth)); + }; + + const { handleDragStart: handleResizeStart, handleDragKeyDown: handleResizeKeyDown } = useDrag( + handleDragMove, + handleDragKeyDown, + ); + + return { handleResizeStart, handleResizeKeyDown }; +}; diff --git a/apps/readest-app/src/hooks/useSwipeToDismiss.ts b/apps/readest-app/src/hooks/useSwipeToDismiss.ts new file mode 100644 index 00000000..4365a50d --- /dev/null +++ b/apps/readest-app/src/hooks/useSwipeToDismiss.ts @@ -0,0 +1,71 @@ +import { useRef } from 'react'; + +import { impactFeedback } from '@tauri-apps/plugin-haptics'; +import { useEnv } from '@/context/EnvContext'; +import { useDrag } from '@/hooks/useDrag'; + +const VELOCITY_THRESHOLD = 0.5; + +export const useSwipeToDismiss = (onDismiss: () => void) => { + const { appService } = useEnv(); + + const panelRef = useRef(null); + const overlayRef = useRef(null); + const panelHeight = useRef(1.0); + + const handleVerticalDragMove = (data: { clientY: number }) => { + const heightFraction = data.clientY / window.innerHeight; + const newTop = Math.max(0.0, Math.min(1, heightFraction)); + panelHeight.current = newTop; + + const panel = panelRef.current; + const overlay = overlayRef.current; + + if (panel && overlay) { + panel.style.transition = 'none'; + panel.style.transform = `translateY(${newTop * 100}%)`; + overlay.style.opacity = `${1 - heightFraction}`; + } + }; + + const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => { + const panel = panelRef.current; + const overlay = overlayRef.current; + + if (!panel || !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); + panel.style.transition = `transform ${transitionDuration}s ease-out`; + panel.style.transform = 'translateY(100%)'; + overlay.style.transition = `opacity ${transitionDuration}s ease-out`; + overlay.style.opacity = '0'; + setTimeout(() => onDismiss(), 300); + if (appService?.hasHaptics) { + impactFeedback('medium'); + } + } else { + panel.style.transition = 'transform 0.3s ease-out'; + panel.style.transform = 'translateY(0%)'; + overlay.style.transition = 'opacity 0.3s ease-out'; + overlay.style.opacity = '0.8'; + if (appService?.hasHaptics) { + impactFeedback('medium'); + } + } + }; + + const handleVerticalDragKeyDown = () => {}; + + const { handleDragStart: handleVerticalDragStart } = useDrag( + handleVerticalDragMove, + handleVerticalDragKeyDown, + handleVerticalDragEnd, + 'row-resize', + ); + + return { panelRef, overlayRef, panelHeight, handleVerticalDragStart }; +};