forked from akai/readest
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:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user