From c6a627af8a9ebc88e3e5e207cafa73d8ec3024ba Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 3 Dec 2025 23:56:35 +0100 Subject: [PATCH] feat: Add `Download` type and document download field, unify preview zoom styling --- frontend/src/app/DocumentsRoute.tsx | 1 - frontend/src/detail/PreviewZoomOverlay.tsx | 303 +++---------------- frontend/src/preview/PreviewContext.tsx | 59 +--- frontend/src/styles/detail/detail-panels.css | 2 - frontend/src/styles/preview/preview-zoom.css | 36 +-- frontend/src/types/assets.ts | 3 +- frontend/src/types/common.ts | 4 + frontend/src/types/documents.ts | 2 + 8 files changed, 76 insertions(+), 334 deletions(-) create mode 100644 frontend/src/types/common.ts diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx index 9f5cf76..efd6028 100644 --- a/frontend/src/app/DocumentsRoute.tsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -81,7 +81,6 @@ const DocumentsRouteContent: React.FC = () => { return ( {content} diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index ce038b5..a8e4579 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -1,6 +1,5 @@ -import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { clamp } from '../utils/math'; import PdfViewer from '../preview/PdfViewer'; import MediaViewer from '../preview/MediaViewer'; import PanelHeader from '../ui/PanelHeader'; @@ -10,200 +9,73 @@ import type { Document } from '../types/documents'; interface PreviewZoomOverlayProps { open?: boolean; - onClose?: () => void; + onClose: () => void; onMaximize?: () => void; document?: Document | null; } -type NaturalSize = { width: number | null; height: number | null }; -type FocusPoint = { xRatio: number; yRatio: number } | null; type DisplayKind = 'image' | 'pdf'; -type DocumentLink = { - url: string; - alt?: string; - mimeType?: string | null; -}; - -type DocumentWithPreview = Document & { documentLink?: DocumentLink }; - -const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => { - const type = entry?.mimeType?.toLowerCase?.() || ''; +const determineDisplayKind = (doc?: Document | null): DisplayKind => { + const type = doc?.mime_type?.toLowerCase?.() || ''; if (type.includes('pdf')) { return 'pdf'; } - if (type.startsWith('image/')) { - return 'image'; - } - const url = entry?.url?.toLowerCase?.() || ''; - if (url.endsWith('.pdf')) { - return 'pdf'; - } return 'image'; }; -const noop = () => { }; - const PreviewZoomOverlay: React.FC = ({ open = false, - onClose = noop, + onClose, onMaximize, - document: overlayDocument = null, + document: inputDocument = null, }) => { - const portalTarget = document.body; - const [isNativeScale, setIsNativeScale] = useState(false); - const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [renderBackdrop, setRenderBackdrop] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false); - const [documentSnapshot, setDocumentSnapshot] = useState(null); + const lastDocumentRef = useRef(null); const scrollRef = useRef(null); - const mediaRef = useRef(null); - const focusRef = useRef(null); - const previouslyFocusedRef = useRef(null); - const visibilityTimerRef = useRef(null); - const displayTimerRef = useRef(null); + const timerRef = useRef(null); - const currentDocument = overlayDocument; + if (inputDocument) { + lastDocumentRef.current = inputDocument; + } + + const downloadUrl = lastDocumentRef.current?.current_version?.download?.url; + const displayKind = determineDisplayKind(lastDocumentRef.current); + const documentTitle = lastDocumentRef.current?.title || undefined; useEffect(() => { - if (currentDocument?.documentLink?.url) { - setDocumentSnapshot(currentDocument); - } - }, [currentDocument]); - - const activeDocument = open && currentDocument?.documentLink?.url ? currentDocument : documentSnapshot; - const documentLink = activeDocument?.documentLink || null; - const displayKind = useMemo(() => determineDisplayKind(documentLink), [documentLink]); - const isPdfDisplay = displayKind === 'pdf'; - const documentTitle = activeDocument?.title || undefined; - const effectiveAlt = documentLink?.alt || documentTitle || 'Document preview'; - - useEffect(() => { - if (visibilityTimerRef.current) { - clearTimeout(visibilityTimerRef.current); - visibilityTimerRef.current = null; - } - if (displayTimerRef.current) { - cancelAnimationFrame(displayTimerRef.current); - displayTimerRef.current = null; + if (timerRef.current) { + clearTimeout(timerRef.current); + cancelAnimationFrame(timerRef.current); + timerRef.current = null; } - if (open && documentLink?.url) { + if (open) { setRenderBackdrop(true); - displayTimerRef.current = requestAnimationFrame(() => { - displayTimerRef.current = requestAnimationFrame(() => { + timerRef.current = requestAnimationFrame(() => { + timerRef.current = requestAnimationFrame(() => { setBackdropVisible(true); + scrollRef.current?.focus?.({ preventScroll: true }); + timerRef.current = null; }); }); - return () => { - if (displayTimerRef.current) { - cancelAnimationFrame(displayTimerRef.current); - displayTimerRef.current = null; - } - }; + } else { + setBackdropVisible(false); + timerRef.current = window.setTimeout(() => { + setRenderBackdrop(false); + timerRef.current = null; + }, 300); } - setBackdropVisible(false); - visibilityTimerRef.current = window.setTimeout(() => { - setRenderBackdrop(false); - }, 260); - return () => { - if (visibilityTimerRef.current) { - clearTimeout(visibilityTimerRef.current); - visibilityTimerRef.current = null; + if (timerRef.current) { + clearTimeout(timerRef.current); + cancelAnimationFrame(timerRef.current); } }; - }, [open, documentLink?.url]); - - useEffect(() => () => { - if (visibilityTimerRef.current) { - clearTimeout(visibilityTimerRef.current); - } - if (displayTimerRef.current) { - cancelAnimationFrame(displayTimerRef.current); - } - }, []); - - useEffect(() => { - }, [isPdfDisplay, open, documentLink?.url]); - - useEffect(() => { - setIsNativeScale(false); - setNaturalSize({ width: null, height: null }); - focusRef.current = null; - const scrollEl = scrollRef.current; - if (scrollEl) { - scrollEl.scrollLeft = 0; - scrollEl.scrollTop = 0; - } - }, [open, documentLink?.url, displayKind]); - - - useEffect(() => { - if (!open || !isNativeScale) { - return; - } - - const scrollEl = scrollRef.current; - const mediaEl = mediaRef.current; - if (!scrollEl || !mediaEl) { - return; - } - - const imageWidth = naturalSize.width || mediaEl.clientWidth; - const imageHeight = naturalSize.height || mediaEl.clientHeight; - if (!(imageWidth > 0 && imageHeight > 0)) { - return; - } - - const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 }; - const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth); - const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight); - - const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2; - const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2; - - scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft); - scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop); - }, [open, isNativeScale, naturalSize.width, naturalSize.height]); - - useEffect(() => { - if (!open) { - previouslyFocusedRef.current?.focus?.(); - previouslyFocusedRef.current = null; - return; - } - - const active = document.activeElement; - previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; }, [open]); - useEffect(() => { - if (!documentLink?.url) { - return; - } - - const scrollEl = scrollRef.current; - if (scrollEl) { - scrollEl.scrollLeft = 0; - scrollEl.scrollTop = 0; - } - focusRef.current = null; - }, [documentLink?.url]); - - useEffect(() => { - if (!renderBackdrop || !documentLink?.url) { - return undefined; - } - - const frame = requestAnimationFrame(() => { - scrollRef.current?.focus?.({ preventScroll: true }); - }); - - return () => cancelAnimationFrame(frame); - }, [renderBackdrop, documentLink?.url]); - const handleKeyDown = (event: React.KeyboardEvent) => { event.stopPropagation(); @@ -213,59 +85,15 @@ const PreviewZoomOverlay: React.FC = ({ const key = event.key; - if (key === ' ' || key === 'Space' || key === 'Spacebar') { - const target = event.target; - if (target instanceof HTMLElement) { - const tag = target.tagName ? target.tagName.toLowerCase() : ''; - if ( - target.isContentEditable - || tag === 'input' - || tag === 'textarea' - || tag === 'select' - ) { - return; - } - } + if (key === ' ' || key === 'Escape') { event.preventDefault(); onClose(); return; } - - if (key === 'Escape') { - event.preventDefault(); - onClose(); - return; - } - }; - const toggleZoomAtPoint = (_clientX: number, _clientY: number) => { - // Zooming is disabled for images as per user request. - // PDFs handle their own zooming via the PdfViewer. - return; - }; - - const handleContentClick = (event: React.MouseEvent) => { - if (isPdfDisplay) { - return; - } - event.stopPropagation(); - toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); - }; - - const shouldRender = renderBackdrop && Boolean(documentLink?.url); - - const stageClassName = isPdfDisplay - ? 'preview-zoom__stage preview-zoom__stage--pdf' - : 'preview-zoom__stage'; - - const containerClassName = [ - 'preview-zoom__scroll', - isNativeScale ? 'preview-zoom__scroll--native' : null, - isPdfDisplay ? 'preview-zoom__scroll--pdf' : null, - ] - .filter(Boolean) - .join(' '); + const stageClassName = 'preview-zoom__stage'; + const containerClassName = 'preview-zoom__scroll'; const backdropClassName = [ 'preview-zoom-backdrop', @@ -274,30 +102,10 @@ const PreviewZoomOverlay: React.FC = ({ .filter(Boolean) .join(' '); - const contentStyle: CSSProperties = isNativeScale - ? { - cursor: 'default', - width: naturalSize.width ? `${naturalSize.width}px` : 'auto', - height: naturalSize.height ? `${naturalSize.height}px` : 'auto', - maxWidth: 'none', - maxHeight: 'none', - touchAction: 'manipulation', - } - : { - cursor: 'default', - maxWidth: 'calc(100vw - 2 * var(--preview-padding, 1.5vmin))', - maxHeight: 'calc(100vh - 2 * var(--preview-padding, 1.5vmin))', - touchAction: 'manipulation', - }; - - if (!shouldRender) { + if (!renderBackdrop) { return null; } - const effectiveDisplay = documentLink; - - const downloadUrl = activeDocument?.current_version?.download?.url; - return createPortal( (
= ({ ref={scrollRef} tabIndex={-1} > - {isPdfDisplay ? ( -
- -
+ {displayKind === 'pdf' ? ( + ) : ( { - mediaRef.current = node as HTMLImageElement; - }} draggable={false} - onLoad={(event) => { - const target = event.currentTarget as HTMLImageElement; - setNaturalSize({ - width: target.naturalWidth || null, - height: target.naturalHeight || null, - }); - }} - onClick={handleContentClick} - style={contentStyle} - mimeType={effectiveDisplay.mimeType || undefined} + onClick={(e) => e.stopPropagation()} + mimeType={lastDocumentRef.current?.mime_type || undefined} /> )}
), - portalTarget, + document.body, ); }; diff --git a/frontend/src/preview/PreviewContext.tsx b/frontend/src/preview/PreviewContext.tsx index 5408f75..6bc992a 100644 --- a/frontend/src/preview/PreviewContext.tsx +++ b/frontend/src/preview/PreviewContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react'; +import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import type { Document } from '../types/documents'; import type { Identifier } from '../types/identifiers'; @@ -20,21 +20,26 @@ export const usePreviewContext = () => { interface PreviewProviderProps { children: React.ReactNode; - ensureDownloadUrl?: (docId: Identifier) => Promise; onNavigate?: (documentId: Identifier) => void; } -export const PreviewProvider: React.FC = ({ children, ensureDownloadUrl, onNavigate }) => { +export const PreviewProvider: React.FC = ({ children, onNavigate }) => { const [previewDoc, setPreviewDoc] = useState(null); - const [previewUrl, setPreviewUrl] = useState(null); + const lastFocusedElement = useRef(null); const openPreview = useCallback((doc: Document) => { + if (!lastFocusedElement.current) { + lastFocusedElement.current = document.activeElement as HTMLElement; + } setPreviewDoc(doc); }, []); const closePreview = useCallback(() => { setPreviewDoc(null); - setPreviewUrl(null); + if (lastFocusedElement.current) { + lastFocusedElement.current.focus(); + lastFocusedElement.current = null; + } }, []); const handleMaximize = useCallback(() => { @@ -44,46 +49,6 @@ export const PreviewProvider: React.FC = ({ children, ensu } }, [previewDoc, onNavigate, closePreview]); - useEffect(() => { - if (!previewDoc) { - setPreviewUrl(null); - return; - } - - let cancelled = false; - if (ensureDownloadUrl) { - ensureDownloadUrl(previewDoc.id) - .then((entry) => { - if (!cancelled && entry?.url) { - setPreviewUrl(entry.url); - } - }) - .catch(() => { - if (!cancelled) { - setPreviewUrl(null); - } - }); - } - - return () => { - cancelled = true; - }; - }, [previewDoc, ensureDownloadUrl]); - - const overlayDocument = useMemo(() => { - if (!previewDoc || !previewUrl) { - return previewDoc; - } - return { - ...previewDoc, - documentLink: { - url: previewUrl, - alt: previewDoc.title, - mimeType: previewDoc.mime_type, - }, - }; - }, [previewDoc, previewUrl]); - const value = useMemo(() => ({ openPreview, closePreview, @@ -93,10 +58,10 @@ export const PreviewProvider: React.FC = ({ children, ensu {children} ); diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css index dec6b3f..5da9340 100644 --- a/frontend/src/styles/detail/detail-panels.css +++ b/frontend/src/styles/detail/detail-panels.css @@ -616,8 +616,6 @@ justify-content: flex-end; } -.document-summary__details {} - .document-summary__details-list { margin: 0; padding: 0; diff --git a/frontend/src/styles/preview/preview-zoom.css b/frontend/src/styles/preview/preview-zoom.css index cb85e33..d4bf97d 100644 --- a/frontend/src/styles/preview/preview-zoom.css +++ b/frontend/src/styles/preview/preview-zoom.css @@ -26,37 +26,32 @@ align-items: center; justify-content: center; z-index: 6000001; - --preview-padding: 1.5vmin; - --header-clearance: calc(2.5rem + 0.5rem); -} - -.preview-zoom__stage--pdf { width: 100%; height: 100%; + --preview-padding: 1.5vmin; + --header-clearance: calc(2.5rem + 0.5rem); } .preview-zoom__image { width: auto; height: auto; box-shadow: 0 32px 120px var(--shadow-deep); + cursor: default; + max-width: calc(100vw - 2 * var(--preview-padding, 1.5vmin)); + max-height: calc(100vh - var(--header-clearance) - 2 * var(--preview-padding, 1.5vmin)); + touch-action: manipulation; } .preview-zoom__scroll { display: flex; align-items: center; justify-content: center; - padding-top: var(--header-clearance); - box-sizing: border-box; -} - -.preview-zoom__scroll--pdf { width: 100%; height: 100%; padding: 0; + padding-top: var(--header-clearance); box-sizing: border-box; overflow: auto; - align-items: flex-start; - justify-content: center; --pdf-viewer-stack-padding: var(--preview-padding); } @@ -64,25 +59,8 @@ outline: none; } -.preview-zoom__scroll--native { - width: 100%; - height: 100%; - overflow: auto; - cursor: zoom-out; - justify-content: flex-start; - align-items: flex-start; -} -.preview-zoom__pdf { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: flex-start; - padding-top: var(--header-clearance); - box-sizing: border-box; -} .preview-zoom__pdf-viewer { width: 100%; diff --git a/frontend/src/types/assets.ts b/frontend/src/types/assets.ts index 56792e8..5a5ed12 100644 --- a/frontend/src/types/assets.ts +++ b/frontend/src/types/assets.ts @@ -1,4 +1,5 @@ import type { Identifier } from './identifiers'; +import type { Download } from './common'; export interface AssetObject { ordinal?: number; @@ -12,7 +13,7 @@ export interface AssetLike { id?: Identifier; asset_type?: string; cardinality?: number | null; - download?: { url: string; expires_at: number } | null; + download?: Download | null; metadata?: Record | null; assets?: Record | AssetLike[] | null; objects?: AssetObject[] | null; diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts new file mode 100644 index 0000000..2837e68 --- /dev/null +++ b/frontend/src/types/common.ts @@ -0,0 +1,4 @@ +export interface Download { + url: string; + expires_at: number; +} diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts index 9c9cfc4..db2aa48 100644 --- a/frontend/src/types/documents.ts +++ b/frontend/src/types/documents.ts @@ -1,5 +1,6 @@ import type { Identifier } from './identifiers'; import type { AssetLike } from './assets'; +import type { Download } from './common'; export interface DocumentTag { id?: Identifier; @@ -18,6 +19,7 @@ export interface DocumentVersion { metadata?: Record & { page_count?: number } | null; size_bytes?: number | string | null; checksum?: string | null; + download?: Download | null; [key: string]: unknown; }