diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index be0d1a80..257b103d 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -39,6 +39,8 @@ interface Props { onShare: () => void; /** Save a copy; adds a Download button to the header. */ onDownload?: () => void; + /** Render in place (a split view's side pane) instead of a full-screen modal. */ + docked?: boolean; } /** @@ -48,7 +50,7 @@ interface Props { * Used by the reader's attachment chips and the Files tab. */ export function AttachmentPreviewModal({ - item, loading, title, onClose, onOpenExternal, onShare, onDownload, + item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -57,6 +59,9 @@ export function AttachmentPreviewModal({ // An image the platform can't decode would stay blank; fall back to the // external viewer instead. Keyed by URI so the next item starts clean. const [failedImageUri, setFailedImageUri] = React.useState(null); + // The image fits the area it is shown in (a pane, a landscape window), + // not a box derived from the window width that ran past the screen. + const [imageArea, setImageArea] = React.useState<{ width: number; height: number } | null>(null); const emlEmail = React.useMemo(() => { if (!item?.eml) return null; @@ -104,13 +109,19 @@ export function AttachmentPreviewModal({ { + const { width: w, height: h } = e.nativeEvent.layout; + setImageArea((prev) => (prev && prev.width === w && prev.height === h ? prev : { width: w, height: h })); + }} maximumZoomScale={4} minimumZoomScale={1} bouncesZoom > setFailedImageUri(item.fileUri ?? null)} @@ -160,9 +171,8 @@ export function AttachmentPreviewModal({ } }; - return ( - - + const content = ( + {renderBody()} + ); + if (docked) return item || loading ? content : null; + return ( + + {content} ); } diff --git a/src/components/files/FilePreviewModal.tsx b/src/components/files/FilePreviewModal.tsx index ba58b746..bb438d43 100644 --- a/src/components/files/FilePreviewModal.tsx +++ b/src/components/files/FilePreviewModal.tsx @@ -25,6 +25,8 @@ interface Props { /** The file to show; null closes the preview. */ file: PreviewableFileNode | null; onClose: () => void; + /** Show it in a side pane instead of a full-screen modal. */ + docked?: boolean; } /** @@ -35,7 +37,7 @@ interface Props { * of fetching it again. The copy is deleted when the preview closes, unless * another app got it, in which case the stale-export sweep removes it. */ -export function FilePreviewModal({ file, onClose }: Props) { +export function FilePreviewModal({ file, onClose, docked = false }: Props) { const t = useLocaleStore((s) => s.t); const [item, setItem] = React.useState(null); const [loading, setLoading] = React.useState(false); @@ -123,6 +125,7 @@ export function FilePreviewModal({ file, onClose }: Props) { item={item} loading={loading} title={name} + docked={docked} onClose={onClose} onOpenExternal={() => act( (local) => shareLocalFile(local, type, name, { keep: true }), diff --git a/src/lib/__tests__/files-grid.test.ts b/src/lib/__tests__/files-grid.test.ts new file mode 100644 index 00000000..8d65017e --- /dev/null +++ b/src/lib/__tests__/files-grid.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { filesGridLayout } from '../files-grid'; + +describe('filesGridLayout', () => { + it('keeps three columns on a phone', () => { + expect(filesGridLayout(411).columns).toBe(3); + }); + + it('adds columns on a wide window instead of growing the cells', () => { + const { columns, cellWidth } = filesGridLayout(1200); + expect(columns).toBeGreaterThan(3); + expect(cellWidth).toBeLessThan(160); + expect(cellWidth).toBeGreaterThanOrEqual(112); + }); + + it('fits the cells and gaps into the width', () => { + for (const width of [360, 411, 800, 1200, 2000]) { + const { columns, cellWidth } = filesGridLayout(width); + expect(cellWidth * columns + 8 * (columns - 1) + 16).toBeLessThanOrEqual(width); + } + }); +}); diff --git a/src/lib/files-grid.ts b/src/lib/files-grid.ts new file mode 100644 index 00000000..fcc46ec4 --- /dev/null +++ b/src/lib/files-grid.ts @@ -0,0 +1,15 @@ +/** + * Columns and cell width of the Files grid for a list `width` wide: as many + * cells of at least `minCell` as fit, never fewer than three. Cells get a + * fixed width, so a short last row keeps the columns of the rows above + * instead of stretching its cells across the row. + */ +export function filesGridLayout( + width: number, + { minCell = 112, padding = 8, gap = 8 }: { minCell?: number; padding?: number; gap?: number } = {}, +): { columns: number; cellWidth: number } { + const inner = Math.max(0, width - padding * 2); + const columns = Math.max(3, Math.floor((inner + gap) / (minCell + gap))); + const cellWidth = Math.max(0, Math.floor((inner - gap * (columns - 1)) / columns)); + return { columns, cellWidth }; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 2a69888c..f11e9d88 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -14,6 +14,7 @@ import { TouchableOpacity, TouchableWithoutFeedback, View, + useWindowDimensions, } from 'react-native'; import { ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, @@ -52,6 +53,8 @@ import { jmapClient } from '../api/jmap-client'; import { downloadAttachment, shareAttachment } from '../lib/email-export'; import { secureFetch } from '../lib/client-cert'; import { getUniqueName } from '../lib/filenode-name'; +import { filesGridLayout } from '../lib/files-grid'; +import { useLayout, useSheetInsets } from '../lib/use-layout'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { filesBackStep } from '../lib/files-back'; import type { FileNode } from '../api/types'; @@ -66,7 +69,6 @@ import Dialog from '../components/Dialog'; import ShareSheet from '../components/files/ShareSheet'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; -import { useSheetInsets } from '../lib/use-layout'; // A file row carries the display name alongside the rest of the node. interface FileRow extends FileNode { @@ -206,6 +208,11 @@ export default function FilesScreen() { const [viewOverride, setViewOverride] = useState(null); const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; + const { isSplit } = useLayout(); + // The grid's columns follow its measured width (rotation, tablet panes). + const { width: windowWidth } = useWindowDimensions(); + const [gridWidth, setGridWidth] = React.useState(0); + const grid = filesGridLayout(gridWidth || windowWidth); const uploading = uploadProgress != null; const selectionMode = selection.size > 0; @@ -987,12 +994,14 @@ export default function FilesScreen() { body = ( item.id} columnWrapperStyle={styles.gridRow} contentContainerStyle={styles.gridContent} - renderItem={({ item }) => renderGridCell(item)} + onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))} + renderItem={({ item }) => {renderGridCell(item)}} keyboardShouldPersistTaps="handled" refreshControl={ @@ -1014,10 +1023,19 @@ export default function FilesScreen() { ); } + // On a wide window an open file shows beside the list instead of covering it. + const previewDocked = isSplit && previewTarget != null; return ( - - {renderHeader()} - {body} + + + {renderHeader()} + {body} + + {previewDocked && ( + + setPreviewTarget(null)} /> + + )} setSetting('filesDefaultSortDir', d)} /> - setPreviewTarget(null)} /> + {!isSplit && setPreviewTarget(null)} />}