fix(layout): respect grid insets for the zoom controller, closes #3426 (#3430)

This commit is contained in:
Huang Xin
2026-03-01 16:58:54 +08:00
committed by GitHub
parent 0d2e5b7c76
commit 431d14f4a4
6 changed files with 89 additions and 7 deletions
+2 -3
View File
@@ -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.
+52
View File
@@ -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
<ImageViewer gridInsets={gridInsets} ... />
<TableViewer gridInsets={gridInsets} ... />
<ZoomControls gridInsets={gridInsets} ... />
```
@@ -594,6 +594,7 @@ const FoliateViewer: React.FC<{
<>
{selectedImage && (
<ImageViewer
gridInsets={gridInsets}
src={selectedImage}
onClose={handleCloseImage}
onPrevious={currentImageIndex > 0 ? handlePreviousImage : undefined}
@@ -602,6 +603,7 @@ const FoliateViewer: React.FC<{
)}
{selectedTableHtml && (
<TableViewer
gridInsets={gridInsets}
html={selectedTableHtml}
isDarkMode={isDarkMode}
onClose={() => setSelectedTableHtml(null)}
@@ -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<ImageViewerProps> = ({ src, onClose, onPrevious, onNext }) => {
const ImageViewer: React.FC<ImageViewerProps> = ({
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<ImageViewerProps> = ({ src, onClose, onPrevious, onN
}}
/>
<ZoomControls
gridInsets={gridInsets}
onClose={onClose}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
@@ -1,9 +1,11 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { Insets } from '@/types/misc';
import ZoomControls from './ZoomControls';
interface TableViewerProps {
gridInsets: Insets;
html: string | null;
isDarkMode: boolean;
onClose: () => void;
@@ -13,7 +15,7 @@ const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const ZOOM_SPEED = 0.1;
const TableViewer: React.FC<TableViewerProps> = ({ html, isDarkMode, onClose }) => {
const TableViewer: React.FC<TableViewerProps> = ({ 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<TableViewerProps> = ({ html, isDarkMode, onClose })
}}
/>
<ZoomControls
gridInsets={gridInsets}
onClose={onClose}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
@@ -1,18 +1,35 @@
import React from 'react';
import { IoClose, IoExpand, IoAdd, IoRemove } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useThemeStore } from '@/store/themeStore';
import { Insets } from '@/types/misc';
interface ZoomControlsProps {
gridInsets: Insets;
onClose: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
}
const ZoomControls: React.FC<ZoomControlsProps> = ({ onClose, onZoomIn, onZoomOut, onReset }) => {
const ZoomControls: React.FC<ZoomControlsProps> = ({
gridInsets,
onClose,
onZoomIn,
onZoomOut,
onReset,
}) => {
const _ = useTranslation();
const { systemUIVisible, statusBarHeight } = useThemeStore();
return (
<div className='absolute right-4 top-4 z-10 grid grid-cols-1 gap-4 text-white'>
<div
className='absolute right-4 top-2 z-10 grid grid-cols-1 gap-4 text-white'
style={{
marginTop: systemUIVisible
? `${Math.max(gridInsets.top, statusBarHeight)}px`
: `${gridInsets.top}px`,
}}
>
<button
onClick={onClose}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'