From 97e800bfd538c7df2cd74de2ba8c90615efddbff Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 16 Oct 2025 17:10:23 +0200 Subject: [PATCH] view ocr text --- frontend/src/detail/DetailPanel.jsx | 140 ++++++++++++++++++++++++++-- frontend/src/styles.css | 44 +++++++++ 2 files changed, 177 insertions(+), 7 deletions(-) diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 07d2260..47f2e26 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; import { DownloadIcon, EditIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; import { formatFileSize } from '../utils/format'; @@ -294,6 +295,11 @@ const DetailPanel = ({ const [titleDraft, setTitleDraft] = useState(''); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); + const [ocrOpen, setOcrOpen] = useState(false); + const [ocrLoading, setOcrLoading] = useState(false); + const [ocrError, setOcrError] = useState(null); + const [ocrText, setOcrText] = useState(''); + const [ocrLoadedDocId, setOcrLoadedDocId] = useState(null); useEffect(() => { if (!singleDoc) { @@ -301,6 +307,11 @@ const DetailPanel = ({ setTitleDraft(''); setTitleError(null); setTitleSaving(false); + setOcrOpen(false); + setOcrLoading(false); + setOcrError(null); + setOcrText(''); + setOcrLoadedDocId(null); return; } @@ -312,6 +323,14 @@ const DetailPanel = ({ } }, [singleDoc, titleEditDocId]); + useEffect(() => { + setOcrOpen(false); + setOcrLoading(false); + setOcrError(null); + setOcrText(''); + setOcrLoadedDocId(null); + }, [singleDoc?.id]); + const startTitleEdit = useCallback(() => { if (!singleDoc) return; setTitleEditDocId(singleDoc.id); @@ -360,6 +379,69 @@ const DetailPanel = ({ [onPromoteSelection], ); + const hasOcrAsset = useMemo( + () => Boolean(singleDoc && getDocumentAsset(singleDoc, 'ocr-text')), + [singleDoc, getDocumentAsset], + ); + + const loadOcrText = useCallback(async () => { + if (!singleDoc) { + return; + } + if (ocrLoadedDocId === singleDoc.id && ocrText) { + return; + } + const asset = getDocumentAsset(singleDoc, 'ocr-text'); + if (!asset) { + setOcrError('No OCR text available for this document.'); + setOcrText(''); + setOcrLoadedDocId(singleDoc.id); + return; + } + setOcrLoading(true); + setOcrError(null); + try { + let entry = asset; + if (ensureAssetUrl) { + entry = (await ensureAssetUrl(singleDoc.id, asset, { force: false })) || asset; + } + let url = entry?.url || null; + if (!url) { + url = resolveDocumentAssetUrl(singleDoc, 'ocr-text', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + } + if (!url) { + throw new Error('OCR text URL is unavailable.'); + } + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch OCR text (status ${response.status}).`); + } + const text = await response.text(); + setOcrText(text); + setOcrLoadedDocId(singleDoc.id); + } catch (error) { + setOcrError(error.message || 'Failed to load OCR text.'); + setOcrText(''); + } finally { + setOcrLoading(false); + } + }, [singleDoc, ensureAssetUrl, getDocumentAsset, ocrLoadedDocId, ocrText]); + + const openOcr = useCallback(() => { + if (!singleDoc) { + return; + } + setOcrOpen(true); + loadOcrText(); + }, [singleDoc, loadOcrText]); + + const closeOcr = useCallback(() => { + setOcrOpen(false); + }, []); + const makePreviewItem = useCallback( (doc) => { if (!doc) return null; @@ -681,14 +763,21 @@ const DetailPanel = ({ > Open preview - + + {hasOcrAsset ? ( +
+ +
+ ) : null} ({ @@ -728,6 +817,43 @@ const DetailPanel = ({
{JSON.stringify(metadata, null, 2)}
)} + {hasOcrAsset && ocrOpen + ? createPortal( +
+
event.stopPropagation()} + > +
+

OCR Text

+ +
+
+ {ocrLoading ? ( +
Loading OCR text…
+ ) : ocrError ? ( +
{ocrError}
+ ) : ocrText ? ( +
{ocrText}
+ ) : ( +
OCR text empty.
+ )} +
+
+
, + document.body, + ) + : null} ); }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9aff6e4..48f610a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1516,6 +1516,50 @@ button.icon-button.ghost:hover:not([disabled]) { margin: 0.6rem 0; } +.detail-ocr-trigger { + margin: 0.75rem 0; +} + +.detail-ocr__content { + max-height: 60vh; + overflow: auto; + padding: 0.75rem; + background: var(--surface-soft); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.05); +} + +.detail-ocr__content pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); + font-size: 0.85rem; + line-height: 1.4; +} + +.modal__body { + width: 100%; +} + +.modal--ocr { + width: min(720px, 100%); + max-height: 80vh; + gap: 0.75rem; +} + +.modal--ocr .modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.modal--ocr .modal__body { + padding: 0; +} + .preview-pane { margin-top: 0.4rem; border: none;