feat: fit the files grid to the width and preview files beside the list on tablets

This commit is contained in:
Linus Rath
2026-10-15 02:07:49 +02:00
parent 8ab82cdeb7
commit 2470e99dc8
5 changed files with 90 additions and 15 deletions
@@ -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<string | null>(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<Email | null>(() => {
if (!item?.eml) return null;
@@ -104,13 +109,19 @@ export function AttachmentPreviewModal({
<ScrollView
style={styles.flex}
contentContainerStyle={styles.imageScroll}
onLayout={(e) => {
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
>
<Image
source={{ uri: item.fileUri }}
style={{ width: width - spacing.lg * 2, height: width * 1.2 }}
style={imageArea
? { width: imageArea.width - spacing.lg * 2, height: imageArea.height - spacing.lg * 2 }
: { width: width - spacing.lg * 2, height: width * 1.2 }}
resizeMode="contain"
accessibilityLabel={item.name}
onError={() => setFailedImageUri(item.fileUri ?? null)}
@@ -160,9 +171,8 @@ export function AttachmentPreviewModal({
}
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
const content = (
<SafeAreaView style={styles.container} edges={docked ? ['top', 'right'] : ['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={onClose}
@@ -210,6 +220,11 @@ export function AttachmentPreviewModal({
</View>
{renderBody()}
</SafeAreaView>
);
if (docked) return item || loading ? content : null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
{content}
</Modal>
);
}
+4 -1
View File
@@ -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<PreviewItem | null>(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 }),
+22
View File
@@ -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);
}
});
});
+15
View File
@@ -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 };
}
+29 -9
View File
@@ -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<FilesViewMode | null>(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 = (
<FlatList
data={visibleFiles}
key="grid"
numColumns={3}
// A new column count needs a fresh list.
key={`grid-${grid.columns}`}
numColumns={grid.columns}
keyExtractor={(item) => item.id}
columnWrapperStyle={styles.gridRow}
contentContainerStyle={styles.gridContent}
renderItem={({ item }) => renderGridCell(item)}
onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))}
renderItem={({ item }) => <View style={{ width: grid.cellWidth }}>{renderGridCell(item)}</View>}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
@@ -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 (
<View style={styles.container}>
{renderHeader()}
{body}
<View style={[styles.container, previewDocked && styles.splitRow]}>
<View style={styles.listColumn}>
{renderHeader()}
{body}
</View>
{previewDocked && (
<View style={styles.previewPane}>
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
</View>
)}
<PromptModal
visible={newFolderOpen}
@@ -1108,7 +1126,7 @@ export default function FilesScreen() {
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
/>
<FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />
{!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
<ShareSheet
node={shareTarget}
@@ -1442,6 +1460,9 @@ function makeStyles(c: ThemePalette) {
flex: 1,
backgroundColor: c.background,
},
splitRow: { flexDirection: 'row' },
listColumn: { flex: 1 },
previewPane: { width: '45%', borderLeftWidth: 1, borderLeftColor: c.border },
header: {
paddingTop: 60,
paddingHorizontal: spacing.md,
@@ -1611,7 +1632,6 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
},
gridItem: {
flex: 1,
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.md,