diff --git a/apps/readest-app/AGENTS.md b/apps/readest-app/AGENTS.md index 4183e048..e1cf58fd 100644 --- a/apps/readest-app/AGENTS.md +++ b/apps/readest-app/AGENTS.md @@ -66,7 +66,6 @@ Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Cu - No lookbehind regex in build output — verified by `check:lookbehind-regex`. - Run `pnpm build-check` (builds both targets + runs all checks) before submitting. -### Formatting +### Safe Area Insets -- Prettier is enforced via husky pre-commit hook + lint-staged. -- Run `pnpm format` to auto-fix. +See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges. diff --git a/apps/readest-app/docs/safe-area-insets.md b/apps/readest-app/docs/safe-area-insets.md new file mode 100644 index 00000000..c9cade17 --- /dev/null +++ b/apps/readest-app/docs/safe-area-insets.md @@ -0,0 +1,52 @@ +## Safe Area Insets + +The app runs on devices with notches, status bars, and rounded corners (iOS, Android). UI elements near screen edges must account for safe area insets to avoid being obscured. + +### Key Concepts + +- **`gridInsets: Insets`** — Per-view insets derived from view settings (header/footer visibility, margins). Calculated by `getViewInsets()` in `src/utils/insets.ts`. Passed as a prop from `BooksGrid` → child components. +- **`statusBarHeight: number`** — OS status bar height (default 24px). Stored in `themeStore`. +- **`systemUIVisible: boolean`** — Whether the system UI (status bar, navigation bar) is currently shown. Stored in `themeStore`. +- **`appService?.hasSafeAreaInset`** — Whether the platform requires safe area handling (mobile devices). + +### Top Inset Rules + +For UI elements anchored to the **top** of the screen (headers, close buttons, overlays): + +```tsx +// When system UI is visible, use the larger of gridInsets.top and statusBarHeight +// When system UI is hidden, use gridInsets.top alone +style={{ + marginTop: systemUIVisible + ? `${Math.max(gridInsets.top, statusBarHeight)}px` + : `${gridInsets.top}px`, +}} +``` + +For containers that need safe area padding at the top: + +```tsx +style={{ + paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px', +}} +``` + +### Bottom Inset Rules + +For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area: + +```tsx +style={{ + paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0, +}} +``` + +### Passing `gridInsets` + +When creating overlay components (image viewers, table viewers, zoom controls, etc.), always pass `gridInsets` as a prop so they can position their controls correctly: + +```tsx + + + +``` diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index f84a52cc..056d8ae6 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -594,6 +594,7 @@ const FoliateViewer: React.FC<{ <> {selectedImage && ( 0 ? handlePreviousImage : undefined} @@ -602,6 +603,7 @@ const FoliateViewer: React.FC<{ )} {selectedTableHtml && ( setSelectedTableHtml(null)} diff --git a/apps/readest-app/src/app/reader/components/ImageViewer.tsx b/apps/readest-app/src/app/reader/components/ImageViewer.tsx index 012b59f3..8ac4ee9d 100644 --- a/apps/readest-app/src/app/reader/components/ImageViewer.tsx +++ b/apps/readest-app/src/app/reader/components/ImageViewer.tsx @@ -3,9 +3,11 @@ import React, { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import { IoChevronBack, IoChevronForward } from 'react-icons/io5'; import { useTranslation } from '@/hooks/useTranslation'; +import { Insets } from '@/types/misc'; import ZoomControls from './ZoomControls'; interface ImageViewerProps { + gridInsets: Insets; src: string | null; onClose: () => void; onPrevious?: () => void; @@ -18,7 +20,13 @@ const ZOOM_SPEED = 0.1; const MOBILE_ZOOM_SPEED = 0.001; const ZOOM_BIAS = 1.05; -const ImageViewer: React.FC = ({ src, onClose, onPrevious, onNext }) => { +const ImageViewer: React.FC = ({ + src, + onClose, + onPrevious, + onNext, + gridInsets, +}) => { const _ = useTranslation(); const [scale, setScale] = useState(1); const [zoomSpeed, setZoomSpeed] = useState(0.1); @@ -391,6 +399,7 @@ const ImageViewer: React.FC = ({ src, onClose, onPrevious, onN }} /> void; @@ -13,7 +15,7 @@ const MIN_SCALE = 0.5; const MAX_SCALE = 4; const ZOOM_SPEED = 0.1; -const TableViewer: React.FC = ({ html, isDarkMode, onClose }) => { +const TableViewer: React.FC = ({ gridInsets, html, isDarkMode, onClose }) => { const _ = useTranslation(); const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); @@ -188,6 +190,7 @@ const TableViewer: React.FC = ({ html, isDarkMode, onClose }) }} /> void; onZoomIn: () => void; onZoomOut: () => void; onReset: () => void; } -const ZoomControls: React.FC = ({ onClose, onZoomIn, onZoomOut, onReset }) => { +const ZoomControls: React.FC = ({ + gridInsets, + onClose, + onZoomIn, + onZoomOut, + onReset, +}) => { const _ = useTranslation(); + const { systemUIVisible, statusBarHeight } = useThemeStore(); return ( -
+