Repository Public
1// Full-screen preview of one file. The viewer only talks to the URLs its PreviewSource gives it, so it serves the2// signed-in owner and the anonymous visitor of a share link alike. File content is hostile: nothing here ever lets a3// document run as HTML or script in the application origin (see the per-kind views).4import './preview.css';5import { lazy, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';6import { createPortal } from 'react-dom';7import { Glyph } from '../glyphs.tsx';8import { useT } from '../i18n.ts';9import type { PreviewSource } from '../types.ts';10import { cx, IconButton } from '../ui.tsx';11import { ArchiveView } from './ArchiveView.tsx';12import { ImageView } from './ImageView.tsx';13import { MediaView } from './MediaView.tsx';14import { Busy, DerivedGate, extLabel, fmtSize, Guard, NoPreview, Note, useDerived, ViewerCtx } from './shared.tsx';15import { SheetView } from './SheetView.tsx';16import { CsvView, JsonView, TextFileView } from './TextViews.tsx';1718// pdf.js and the Markdown pipeline are only downloaded when a document needs them19const PdfView = lazy(() => import('./PdfView.tsx').then((m) => ({ default: m.PdfView })));20const MarkdownView = lazy(() => import('./MarkdownView.tsx').then((m) => ({ default: m.MarkdownView })));2122/** Pictures, films and pages are shown on black, whatever the theme: the content is the only light on screen. */23const IMMERSIVE = new Set(['image', 'svg', 'heic', 'video', 'audio', 'pdf', 'office']);24const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),video[controls],audio[controls],[tabindex]:not([tabindex="-1"])';2526function OfficeView({ source }: { source: PreviewSource }) {27 const t = useT();28 const url = source.previewUrl('pdf');29 const [state, retry] = useDerived<undefined>(url, 'probe');30 return (31 <DerivedGate state={state} retry={retry} source={source}>32 {() => <><Note testId="converted-note">{t('preview.convertedNote')}</Note><div className="min-h-0 flex-1"><PdfView url={url} source={source} /></div></>}33 </DerivedGate>34 );35}36function HeicView({ source }: { source: PreviewSource }) {37 const url = source.previewUrl('image');38 const [state, retry] = useDerived<undefined>(url, 'probe');39 return <DerivedGate state={state} retry={retry} source={source}>{() => <ImageView src={url} source={source} />}</DerivedGate>;40}4142function Body({ source }: { source: PreviewSource }) {43 switch (source.kind) {44 case 'pdf': return <PdfView url={source.contentUrl()} source={source} />;45 case 'office': return <OfficeView source={source} />;46 case 'image': return <ImageView src={source.contentUrl()} source={source} />;47 case 'svg': return <ImageView src={source.contentUrl()} source={source} vector />;48 case 'heic': return <HeicView source={source} />;49 case 'video': case 'audio': return <MediaView source={source} />;50 case 'text': return <TextFileView source={source} />;51 case 'markdown': return <MarkdownView source={source} />;52 case 'csv': return <CsvView source={source} />;53 case 'json': return <JsonView source={source} />;54 case 'sheet': return <SheetView source={source} />;55 case 'archive': return <ArchiveView source={source} />;56 default: return <NoPreview source={source} />;57 }58}5960/** Is the touch inside something that scrolls sideways (a wide table, a zoomed page)? Then a swipe belongs to it. */61function scrollsSideways(target: EventTarget | null, stop: HTMLElement): boolean {62 for (let el = target as HTMLElement | null; el && el !== stop; el = el.parentElement) {63 if (el.scrollWidth > el.clientWidth + 2) { const o = getComputedStyle(el).overflowX; if (o === 'auto' || o === 'scroll') return true; }64 }65 return false;66}6768/** Full-screen preview of one file. `actions` are extra buttons for the toolbar (share, details…). */69export function Viewer({ source, onClose, onPrev, onNext, actions }: { source: PreviewSource; onClose: () => void; onPrev?: () => void; onNext?: () => void; actions?: ReactNode }) {70 const t = useT();71 const rootRef = useRef<HTMLDivElement>(null);72 const titleId = useId();73 const [toolsEl, setToolsEl] = useState<HTMLElement | null>(null);74 const swipeLocked = useRef(false);75 const ctx = useMemo(() => ({ toolsEl, setSwipeLocked: (v: boolean) => { swipeLocked.current = v; } }), [toolsEl]);76 const live = useRef({ onClose, onPrev, onNext });77 live.current = { onClose, onPrev, onNext };7879 // focus goes in, stays in, and returns where it was; the page behind does not scroll80 useLayoutEffect(() => {81 const prev = document.activeElement as HTMLElement | null;82 rootRef.current?.focus({ preventScroll: true });83 const overflow = document.body.style.overflow; document.body.style.overflow = 'hidden';84 return () => { document.body.style.overflow = overflow; prev?.focus?.({ preventScroll: true }); };85 }, []);86 useEffect(() => { swipeLocked.current = false; }, [source.id]);8788 useEffect(() => {89 const onKey = (e: KeyboardEvent) => {90 const root = rootRef.current; if (!root) return;91 const target = e.target as HTMLElement | null;92 // keys typed in a dialog or menu opened on top of the viewer (from `actions`) are not ours93 if (target && target !== document.body && !root.contains(target)) return;94 if (e.defaultPrevented) return;95 if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); live.current.onClose(); return; }96 if (e.key === 'Tab') {97 const f = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((x) => x.offsetParent !== null);98 if (!f.length) { e.preventDefault(); root.focus(); return; }99 const first = f[0]!, last = f[f.length - 1]!, active = document.activeElement;100 if (!root.contains(active) || active === root) { e.preventDefault(); (e.shiftKey ? last : first).focus(); }101 else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }102 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }103 return;104 }105 if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {106 if (target && (/^(INPUT|TEXTAREA|SELECT|VIDEO|AUDIO)$/.test(target.tagName) || target.isContentEditable)) return;107 if (target && scrollsSideways(target, root)) return; // the arrows scroll that element108 const go = e.key === 'ArrowLeft' ? live.current.onPrev : live.current.onNext;109 if (go) { e.preventDefault(); go(); }110 }111 };112 window.addEventListener('keydown', onKey);113 return () => window.removeEventListener('keydown', onKey);114 }, []);115116 // horizontal swipe between files on touch screens, unless the content is zoomed or scrolls sideways itself117 const touch = useRef<{ x: number; y: number; at: number; ok: boolean } | null>(null);118 const onTouchStart = useCallback((e: React.TouchEvent) => {119 const p = e.touches[0]!;120 touch.current = { x: p.clientX, y: p.clientY, at: Date.now(), ok: e.touches.length === 1 && !swipeLocked.current && !scrollsSideways(e.target, e.currentTarget as HTMLElement) && !(e.target as HTMLElement).closest('video,audio,input,[data-noswipe]') };121 }, []);122 const onTouchMove = useCallback((e: React.TouchEvent) => { if (e.touches.length > 1 && touch.current) touch.current.ok = false; }, []);123 const onTouchEnd = useCallback((e: React.TouchEvent) => {124 const s = touch.current; touch.current = null;125 if (!s?.ok || swipeLocked.current) return;126 const p = e.changedTouches[0]!;127 const dx = p.clientX - s.x, dy = p.clientY - s.y;128 if (Math.abs(dx) < 70 || Math.abs(dx) < 2.2 * Math.abs(dy) || Date.now() - s.at > 700) return;129 (dx > 0 ? live.current.onPrev : live.current.onNext)?.();130 }, []);131132 const ext = extLabel(source.name);133 const navBtn = 'press inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-ink-2 hover:bg-hover hover:text-ink';134 return createPortal(135 <div ref={rootRef} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} data-testid="viewer" data-kind={source.kind}136 className={cx('anim-fade fixed inset-0 z-50 flex flex-col bg-bg text-ink outline-none', IMMERSIVE.has(source.kind) && 'dark')}>137 <header className="safe-t z-10 shrink-0 bg-surface/80 shadow-[0_1px_0_var(--line)] backdrop-blur-xl">138 <div className="flex flex-wrap items-center gap-x-1 px-2 sm:flex-nowrap sm:gap-x-2 sm:px-3" style={{ paddingLeft: 'max(env(safe-area-inset-left), 0.5rem)', paddingRight: 'max(env(safe-area-inset-right), 0.5rem)' }}>139 <div className="order-1 min-w-0 flex-1 py-2 pl-2 sm:max-w-[38%] sm:flex-initial sm:basis-auto">140 <h2 id={titleId} className="truncate text-[15px] font-bold leading-tight tracking-tight" title={source.name} data-testid="viewer-name">{source.name}</h2>141 <p className="truncate text-xs text-ink-3" data-testid="viewer-meta">{[fmtSize(source.size), ext].filter(Boolean).join(' · ')}</p>142 </div>143 <div className="order-3 flex w-full min-w-0 items-center gap-1 sm:order-2 sm:w-auto sm:flex-1">144 {onPrev ? <button type="button" className={cx(navBtn, 'sm:hidden')} aria-label={t('preview.previous')} title={t('preview.previous')} onClick={onPrev} data-testid="viewer-prev"><Glyph name="chevronLeft" size={22} /></button> : onNext ? <span className="w-10 shrink-0 sm:hidden" /> : null}145 <div ref={setToolsEl} role="toolbar" aria-label={t('preview.tools')} data-testid="viewer-tools" className="flex min-w-0 flex-1 items-center justify-start gap-0.5 overflow-x-auto empty:hidden sm:justify-center [scrollbar-width:none]" />146 {onNext ? <button type="button" className={cx(navBtn, 'ml-auto sm:hidden')} aria-label={t('preview.next')} title={t('preview.next')} onClick={onNext} data-testid="viewer-next"><Glyph name="chevronRight" size={22} /></button> : null}147 </div>148 <div className="order-2 flex shrink-0 items-center gap-0.5 sm:order-3">149 {actions}150 {source.canDownload ? (151 <a href={source.contentUrl({ download: true })} download={source.name} aria-label={t('common.download')} title={t('common.download')} data-testid="viewer-download"152 className="press inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-ink-2 hover:bg-hover hover:text-ink"><Glyph name="download" size={20} /></a>153 ) : null}154 <IconButton icon="x" label={t('common.close')} onClick={onClose} data-testid="viewer-close" />155 </div>156 </div>157 </header>158 <div className="relative flex min-h-0 flex-1 flex-col" onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} onTouchCancel={() => { touch.current = null; }}159 style={{ paddingLeft: 'env(safe-area-inset-left)', paddingRight: 'env(safe-area-inset-right)' }}>160 <ViewerCtx.Provider value={ctx}>161 <Suspense fallback={<Busy label={t('common.loading')} />}>162 <div key={source.id} className="safe-b flex min-h-0 flex-1 flex-col" data-testid="viewer-body"><Guard source={source}><Body source={source} /></Guard></div>163 </Suspense>164 </ViewerCtx.Provider>165 {onPrev ? <button type="button" onClick={onPrev} aria-label={t('preview.previous')} title={t('preview.previous')} data-testid="viewer-prev"166 className="absolute left-3 top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full glass press text-ink shadow-pop sm:flex"><Glyph name="chevronLeft" size={22} /></button> : null}167 {onNext ? <button type="button" onClick={onNext} aria-label={t('preview.next')} title={t('preview.next')} data-testid="viewer-next"168 className="absolute right-3 top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full glass press text-ink shadow-pop sm:flex"><Glyph name="chevronRight" size={22} /></button> : null}169 </div>170 </div>, document.body);171}172