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>
This commit is contained in:
Huang Xin
2026-03-17 11:27:27 +08:00
committed by GitHub
parent 1af55e24da
commit b00d321817
5 changed files with 193 additions and 107 deletions
@@ -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<BookNote[] | null>(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 && (
<Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/20')}
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={handleClickOverlay}
/>
)}
<div
ref={notebookRef}
className={clsx(
'notebook-container right-0 flex min-w-60 select-none flex-col',
'full-height font-sans text-base font-normal sm:text-sm',
@@ -272,9 +272,21 @@ const Notebook: React.FC = ({}) => {
: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@media (max-width: 640px) {
.notebook-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 -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
isMobile && 'hidden',
)}
role='slider'
tabIndex={0}
@@ -286,6 +298,20 @@ const Notebook: React.FC = ({}) => {
onKeyDown={handleDragKeyDown}
/>
<div className='flex-shrink-0'>
{isMobile && (
<div
role='slider'
tabIndex={0}
aria-label={_('Resize Notebook')}
aria-orientation='vertical'
aria-valuenow={notebookHeight.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>
)}
<NotebookHeader
isPinned={isNotebookPinned}
isSearchBarVisible={isSearchBarVisible && notebookActiveTab === 'notes'}
@@ -1,7 +1,6 @@
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';
@@ -10,7 +9,8 @@ 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 { useSwipeToDismiss } from '@/hooks/useSwipeToDismiss';
import { usePanelResize } from '@/hooks/usePanelResize';
import { useThemeStore } from '@/store/themeStore';
import { Overlay } from '@/components/Overlay';
import useShortcuts from '@/hooks/useShortcuts';
@@ -24,8 +24,6 @@ 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();
@@ -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<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(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);
+3 -2
View File
@@ -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(
@@ -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 };
};
@@ -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<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(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 };
};