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; onShare: () => void;
/** Save a copy; adds a Download button to the header. */ /** Save a copy; adds a Download button to the header. */
onDownload?: () => void; 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. * Used by the reader's attachment chips and the Files tab.
*/ */
export function AttachmentPreviewModal({ export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload, item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
}: Props) { }: Props) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); 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 // 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. // external viewer instead. Keyed by URI so the next item starts clean.
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null); 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>(() => { const emlEmail = React.useMemo<Email | null>(() => {
if (!item?.eml) return null; if (!item?.eml) return null;
@@ -104,13 +109,19 @@ export function AttachmentPreviewModal({
<ScrollView <ScrollView
style={styles.flex} style={styles.flex}
contentContainerStyle={styles.imageScroll} 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} maximumZoomScale={4}
minimumZoomScale={1} minimumZoomScale={1}
bouncesZoom bouncesZoom
> >
<Image <Image
source={{ uri: item.fileUri }} 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" resizeMode="contain"
accessibilityLabel={item.name} accessibilityLabel={item.name}
onError={() => setFailedImageUri(item.fileUri ?? null)} onError={() => setFailedImageUri(item.fileUri ?? null)}
@@ -160,9 +171,8 @@ export function AttachmentPreviewModal({
} }
}; };
return ( const content = (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}> <SafeAreaView style={styles.container} edges={docked ? ['top', 'right'] : ['top', 'bottom', 'left', 'right']}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}> <View style={styles.header}>
<Pressable <Pressable
onPress={onClose} onPress={onClose}
@@ -210,6 +220,11 @@ export function AttachmentPreviewModal({
</View> </View>
{renderBody()} {renderBody()}
</SafeAreaView> </SafeAreaView>
);
if (docked) return item || loading ? content : null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
{content}
</Modal> </Modal>
); );
} }
+4 -1
View File
@@ -25,6 +25,8 @@ interface Props {
/** The file to show; null closes the preview. */ /** The file to show; null closes the preview. */
file: PreviewableFileNode | null; file: PreviewableFileNode | null;
onClose: () => void; 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 * 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. * 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 t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null); const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
@@ -123,6 +125,7 @@ export function FilePreviewModal({ file, onClose }: Props) {
item={item} item={item}
loading={loading} loading={loading}
title={name} title={name}
docked={docked}
onClose={onClose} onClose={onClose}
onOpenExternal={() => act( onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }), (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, TouchableOpacity,
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { import {
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, 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 { downloadAttachment, shareAttachment } from '../lib/email-export';
import { secureFetch } from '../lib/client-cert'; import { secureFetch } from '../lib/client-cert';
import { getUniqueName } from '../lib/filenode-name'; 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 { useBackWhileFocused } from '../lib/use-back-while-focused';
import { filesBackStep } from '../lib/files-back'; import { filesBackStep } from '../lib/files-back';
import type { FileNode } from '../api/types'; import type { FileNode } from '../api/types';
@@ -66,7 +69,6 @@ import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet'; import ShareSheet from '../components/files/ShareSheet';
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; 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. // A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode { interface FileRow extends FileNode {
@@ -206,6 +208,11 @@ export default function FilesScreen() {
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null); const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; 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 uploading = uploadProgress != null;
const selectionMode = selection.size > 0; const selectionMode = selection.size > 0;
@@ -987,12 +994,14 @@ export default function FilesScreen() {
body = ( body = (
<FlatList <FlatList
data={visibleFiles} data={visibleFiles}
key="grid" // A new column count needs a fresh list.
numColumns={3} key={`grid-${grid.columns}`}
numColumns={grid.columns}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
columnWrapperStyle={styles.gridRow} columnWrapperStyle={styles.gridRow}
contentContainerStyle={styles.gridContent} 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" keyboardShouldPersistTaps="handled"
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} /> <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 ( return (
<View style={styles.container}> <View style={[styles.container, previewDocked && styles.splitRow]}>
{renderHeader()} <View style={styles.listColumn}>
{body} {renderHeader()}
{body}
</View>
{previewDocked && (
<View style={styles.previewPane}>
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
</View>
)}
<PromptModal <PromptModal
visible={newFolderOpen} visible={newFolderOpen}
@@ -1108,7 +1126,7 @@ export default function FilesScreen() {
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)} onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
/> />
<FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} /> {!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
<ShareSheet <ShareSheet
node={shareTarget} node={shareTarget}
@@ -1442,6 +1460,9 @@ function makeStyles(c: ThemePalette) {
flex: 1, flex: 1,
backgroundColor: c.background, backgroundColor: c.background,
}, },
splitRow: { flexDirection: 'row' },
listColumn: { flex: 1 },
previewPane: { width: '45%', borderLeftWidth: 1, borderLeftColor: c.border },
header: { header: {
paddingTop: 60, paddingTop: 60,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
@@ -1611,7 +1632,6 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm, gap: spacing.sm,
}, },
gridItem: { gridItem: {
flex: 1,
alignItems: 'center', alignItems: 'center',
gap: spacing.xs, gap: spacing.xs,
paddingVertical: spacing.md, paddingVertical: spacing.md,