fix(sidebar): use position fixed and transform for mobile sidebar (#3490)

* chore: bump nodejs version to 24

* fix(sidebar): use position fixed and transform for mobile sidebar

Use position: fixed to prevent horizontal scrolling on the mobile
bottom sheet, and replace style.top with transform: translateY() for
smooth drag performance. Cache element refs to avoid
document.querySelector on every drag frame. Apply the same position:
fixed fix to the notebook panel. Closes #3492

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-03-09 01:04:37 +08:00 committed by GitHub
parent 9f8894c1e0
commit 93b96d64eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 27 additions and 32 deletions

View file

@ -49,7 +49,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: cache Next.js build

View file

@ -159,7 +159,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: setup Java (for Android build only)

View file

@ -122,8 +122,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
```bash
nvm install v22
nvm use v22
nvm install v24
nvm use v24
npm install -g pnpm
rustup update
```

View file

@ -242,6 +242,7 @@ 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 ? (
<>
@ -264,22 +265,14 @@ const Notebook: React.FC = ({}) => {
aria-label={_('Notebook')}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${notebookWidth}`,
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
position: isNotebookPinned ? 'relative' : 'absolute',
width: isMobile ? '100%' : `${notebookWidth}`,
maxWidth: isMobile ? '100%' : `${MAX_NOTEBOOK_WIDTH * 100}%`,
position: isMobile ? 'fixed' : isNotebookPinned ? 'relative' : 'absolute',
paddingTop: systemUIVisible
? `${Math.max(safeAreaInsets?.top || 0, statusBarHeight)}px`
: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@media (max-width: 640px) {
.notebook-container {
width: 100%;
min-width: 100%;
}
}
`}</style>
<div
className={clsx(
'drag-bar absolute -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',

View file

@ -72,11 +72,16 @@ const SideBar = ({}) => {
}
};
const sidebarRef = useRef<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(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]);
@ -102,18 +107,19 @@ const SideBar = ({}) => {
const newTop = Math.max(0.0, Math.min(1, heightFraction));
sidebarHeight.current = newTop;
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
const sidebar = sidebarRef.current;
const overlay = overlayRef.current;
if (sidebar && overlay) {
sidebar.style.top = `${newTop * 100}%`;
sidebar.style.transition = 'none';
sidebar.style.transform = `translateY(${newTop * 100}%)`;
overlay.style.opacity = `${1 - heightFraction}`;
}
};
const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => {
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
const sidebar = sidebarRef.current;
const overlay = overlayRef.current;
if (!sidebar || !overlay) return;
@ -122,8 +128,8 @@ const SideBar = ({}) => {
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
) {
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
sidebar.style.transition = `top ${transitionDuration}s ease-out`;
sidebar.style.top = '100%';
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);
@ -131,8 +137,8 @@ const SideBar = ({}) => {
impactFeedback('medium');
}
} else {
sidebar.style.transition = 'top 0.3s ease-out';
sidebar.style.top = '0%';
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) {
@ -236,6 +242,7 @@ const SideBar = ({}) => {
/>
)}
<div
ref={sidebarRef}
className={clsx(
'sidebar-container flex min-w-60 select-none flex-col',
'full-height transition-[padding-top] duration-300',
@ -248,9 +255,9 @@ const SideBar = ({}) => {
aria-label={_('Sidebar')}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${sideBarWidth}`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isSideBarPinned ? 'relative' : 'absolute',
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`,
@ -259,14 +266,9 @@ const SideBar = ({}) => {
<style jsx>{`
@media (max-width: 640px) {
.sidebar-container {
width: 100%;
min-width: 100%;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.sidebar-container.open {
top: 0%;
}
.overlay {
transition: opacity 0.3s ease-in-out;
}