From 5edad2de6ae665cc6160aebe405f46e936886df0 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Mon, 26 Oct 2026 01:27:26 -0700 Subject: [PATCH] =?UTF-8?q?Files=20viewer:=20previous/next=20=E2=80=94=20b?= =?UTF-8?q?uttons,=20=E2=86=90=20=E2=86=92,=20swipe=20on=20pictures;=20ste?= =?UTF-8?q?ps=20through=20media=20when=20media=20is=20open;=20audio=20auto?= =?UTF-8?q?plays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/Files.tsx | 64 +++++++++++++++++++++++++++++++++++++++++----- web/src/styles.css | 4 +++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 2eb37bf..3c7c25e 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -1,4 +1,4 @@ -import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { DragEvent, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api' import { fmtBytes } from './fmt' import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui' @@ -22,6 +22,7 @@ export function resolvePath(input: string, here: string): string { } const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) } const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n) +const isMedia = (e: FileEntry) => !!e.preview || isImage(e.name) const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'" const MAX_EDIT = 2 * 1024 * 1024 @@ -198,7 +199,9 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool } // ---- editor / viewer ---- -function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) { +// steps: the files ← and → go through, in the list's order (only the pictures, videos and +// sound when one of those is open). +function Viewer({ path, sudo, steps, onStep, onClose, onChanged }: { path: string; sudo: boolean; steps: string[]; onStep: (p: string) => void; onClose: () => void; onChanged: () => void }) { const [info, setInfo] = useState(null) const [text, setText] = useState(null) const [orig, setOrig] = useState('') @@ -251,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea }, [dirty, name]) const dl = api.fileDownloadURL(path, sudo) + // Previous and next: the buttons, ← and → (unless typing, or the player has the focus and + // seeks with them), and a sideways swipe on a picture. + const at = steps.indexOf(path) + const prev = at > 0 ? steps[at - 1] : null + const next = at >= 0 && at < steps.length - 1 ? steps[at + 1] : null + const step = useCallback(async (to: string | null) => { + if (!to) return + if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return + setUnsaved(''); onStep(to) + }, [dirty, onStep]) + useEffect(() => { + const h = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return + const t = e.target as HTMLElement | null + if (t?.closest('input, textarea, select, [contenteditable], .vp')) return + if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); step(prev) } + else if (e.key === 'ArrowRight' && next) { e.preventDefault(); step(next) } + } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [prev, next, step]) + // The next and previous pictures load now, so stepping onto them is instant. + useEffect(() => { + for (const p of [prev, next]) if (p && isImage(p)) new Image().src = api.fileReadURL(p, sudo) + }, [prev, next, sudo]) + const swipe = useRef<{ x: number; y: number } | null>(null) + const swipeEnd = (e: PointerEvent) => { + const s = swipe.current + swipe.current = null + if (!s) return + const dx = e.clientX - s.x, dy = e.clientY - s.y + if (Math.abs(dx) > 50 && Math.abs(dx) > 1.5 * Math.abs(dy)) step(dx < 0 ? next : prev) + } + return (
+ {at >= 0 && steps.length > 1 && <> + + {at + 1}/{steps.length} + + } {name}{dirty && •} {info && {fmtBytes(info.size)} · {info.perm} · {info.user}} @@ -266,11 +308,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea {info && text !== null && (