feat: Add OCR script for PDFs using Ollama and refine document preview navigation and zoom behavior.
This commit is contained in:
@@ -35,6 +35,10 @@ const DocumentsRouteContent: React.FC = () => {
|
|||||||
navigate(target);
|
navigate(target);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleDocumentNavigate = useCallback((documentId: string) => {
|
||||||
|
navigate(`/documents/${documentId}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const documentsTablePropsWithNav = useMemo(() => (
|
const documentsTablePropsWithNav = useMemo(() => (
|
||||||
surfaceConfig.documentsTableProps
|
surfaceConfig.documentsTableProps
|
||||||
? { ...surfaceConfig.documentsTableProps, onBreadcrumbNavigate: handleHeaderBreadcrumbClick }
|
? { ...surfaceConfig.documentsTableProps, onBreadcrumbNavigate: handleHeaderBreadcrumbClick }
|
||||||
@@ -76,7 +80,10 @@ const DocumentsRouteContent: React.FC = () => {
|
|||||||
const content = renderSurface();
|
const content = renderSurface();
|
||||||
return (
|
return (
|
||||||
<DocumentsFilterProvider value={documentsFilter}>
|
<DocumentsFilterProvider value={documentsFilter}>
|
||||||
<PreviewProvider ensureDownloadUrl={surfaceConfig.documentsTableProps?.ensureDownloadUrl}>
|
<PreviewProvider
|
||||||
|
ensureDownloadUrl={surfaceConfig.documentsTableProps?.ensureDownloadUrl}
|
||||||
|
onNavigate={handleDocumentNavigate}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</PreviewProvider>
|
</PreviewProvider>
|
||||||
</DocumentsFilterProvider>
|
</DocumentsFilterProvider>
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { createPortal } from 'react-dom';
|
|||||||
import { clamp } from '../utils/math';
|
import { clamp } from '../utils/math';
|
||||||
import PdfViewer from '../preview/PdfViewer';
|
import PdfViewer from '../preview/PdfViewer';
|
||||||
import MediaViewer from '../preview/MediaViewer';
|
import MediaViewer from '../preview/MediaViewer';
|
||||||
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
|
import { IconX, DownloadIcon, FileInfoIcon } from '../ui/icons';
|
||||||
|
|
||||||
import type { Document } from '../types/documents';
|
import type { Document } from '../types/documents';
|
||||||
|
|
||||||
interface PreviewZoomOverlayProps {
|
interface PreviewZoomOverlayProps {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onMaximize?: () => void;
|
||||||
document?: Document | null;
|
document?: Document | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ const noop = () => { };
|
|||||||
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||||
open = false,
|
open = false,
|
||||||
onClose = noop,
|
onClose = noop,
|
||||||
|
onMaximize,
|
||||||
document: overlayDocument = null,
|
document: overlayDocument = null,
|
||||||
}) => {
|
}) => {
|
||||||
const portalTarget = document.body;
|
const portalTarget = document.body;
|
||||||
@@ -235,25 +239,10 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZoomAtPoint = (clientX: number, clientY: number) => {
|
const toggleZoomAtPoint = (_clientX: number, _clientY: number) => {
|
||||||
if (isPdfDisplay) {
|
// Zooming is disabled for images as per user request.
|
||||||
return;
|
// PDFs handle their own zooming via the PdfViewer.
|
||||||
}
|
return;
|
||||||
const media = mediaRef.current;
|
|
||||||
setIsNativeScale((current) => {
|
|
||||||
if (!current && media) {
|
|
||||||
const rect = media.getBoundingClientRect();
|
|
||||||
const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
|
|
||||||
const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
|
|
||||||
focusRef.current = {
|
|
||||||
xRatio: clamp(xRatio, 0, 1),
|
|
||||||
yRatio: clamp(yRatio, 0, 1),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
focusRef.current = null;
|
|
||||||
}
|
|
||||||
return !current;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContentClick = (event: React.MouseEvent<HTMLElement>) => {
|
const handleContentClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
@@ -287,7 +276,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
|
|
||||||
const contentStyle: CSSProperties = isNativeScale
|
const contentStyle: CSSProperties = isNativeScale
|
||||||
? {
|
? {
|
||||||
cursor: 'zoom-out',
|
cursor: 'default',
|
||||||
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||||
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||||
maxWidth: 'none',
|
maxWidth: 'none',
|
||||||
@@ -295,7 +284,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
touchAction: 'manipulation',
|
touchAction: 'manipulation',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
cursor: 'zoom-in',
|
cursor: 'default',
|
||||||
maxWidth: 'calc(100vw - 2 * var(--preview-padding, 1.5vmin))',
|
maxWidth: 'calc(100vw - 2 * var(--preview-padding, 1.5vmin))',
|
||||||
maxHeight: 'calc(100vh - 2 * var(--preview-padding, 1.5vmin))',
|
maxHeight: 'calc(100vh - 2 * var(--preview-padding, 1.5vmin))',
|
||||||
touchAction: 'manipulation',
|
touchAction: 'manipulation',
|
||||||
@@ -307,6 +296,8 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
|
|
||||||
const effectiveDisplay = documentLink;
|
const effectiveDisplay = documentLink;
|
||||||
|
|
||||||
|
const downloadUrl = activeDocument?.current_version?.download?.url;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
(
|
(
|
||||||
<div
|
<div
|
||||||
@@ -317,6 +308,47 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
|
<PanelHeader
|
||||||
|
className="panel-header--dark"
|
||||||
|
title={documentTitle}
|
||||||
|
leading={
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Close preview"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<IconX />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{downloadUrl && (
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Download document"
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{onMaximize && (
|
||||||
|
<button
|
||||||
|
onClick={onMaximize}
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Open document info"
|
||||||
|
title="Open document info"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FileInfoIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={stageClassName}
|
className={stageClassName}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -379,7 +379,8 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
|
|||||||
const stackStyle = useMemo<CSSProperties>(() => ({
|
const stackStyle = useMemo<CSSProperties>(() => ({
|
||||||
'--pdf-viewer-viewport-width': `${viewportWidth}px`,
|
'--pdf-viewer-viewport-width': `${viewportWidth}px`,
|
||||||
'--pdf-viewer-viewport-height': `${viewportHeight}px`,
|
'--pdf-viewer-viewport-height': `${viewportHeight}px`,
|
||||||
}), [viewportHeight, viewportWidth]);
|
cursor: viewMode === 'fit-width' ? 'zoom-out' : 'zoom-in',
|
||||||
|
}), [viewportHeight, viewportWidth, viewMode]);
|
||||||
|
|
||||||
const toggleViewMode = useCallback(() => {
|
const toggleViewMode = useCallback(() => {
|
||||||
setViewMode((prev) => (prev === 'fit-width' ? 'contain' : 'fit-width'));
|
setViewMode((prev) => (prev === 'fit-width' ? 'contain' : 'fit-width'));
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ export const usePreviewContext = () => {
|
|||||||
interface PreviewProviderProps {
|
interface PreviewProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
ensureDownloadUrl?: (docId: Identifier) => Promise<any>;
|
ensureDownloadUrl?: (docId: Identifier) => Promise<any>;
|
||||||
|
onNavigate?: (documentId: Identifier) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PreviewProvider: React.FC<PreviewProviderProps> = ({ children, ensureDownloadUrl }) => {
|
export const PreviewProvider: React.FC<PreviewProviderProps> = ({ children, ensureDownloadUrl, onNavigate }) => {
|
||||||
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
|
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -36,6 +37,13 @@ export const PreviewProvider: React.FC<PreviewProviderProps> = ({ children, ensu
|
|||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleMaximize = useCallback(() => {
|
||||||
|
if (previewDoc && onNavigate) {
|
||||||
|
onNavigate(previewDoc.id);
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
}, [previewDoc, onNavigate, closePreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewDoc) {
|
if (!previewDoc) {
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
@@ -87,6 +95,7 @@ export const PreviewProvider: React.FC<PreviewProviderProps> = ({ children, ensu
|
|||||||
<PreviewZoomOverlay
|
<PreviewZoomOverlay
|
||||||
open={Boolean(previewUrl)}
|
open={Boolean(previewUrl)}
|
||||||
onClose={closePreview}
|
onClose={closePreview}
|
||||||
|
onMaximize={onNavigate ? handleMaximize : undefined}
|
||||||
document={overlayDocument}
|
document={overlayDocument}
|
||||||
/>
|
/>
|
||||||
</PreviewContext.Provider>
|
</PreviewContext.Provider>
|
||||||
|
|||||||
@@ -75,6 +75,28 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark theme variant for preview zoom overlay */
|
||||||
|
.panel-header--dark {
|
||||||
|
background: var(--overlay-dark);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header--dark .icon-button,
|
||||||
|
.panel-header--dark button,
|
||||||
|
.panel-header--dark a.icon-button {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
opacity: 0.75;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header--dark .icon-button:hover:not([disabled]),
|
||||||
|
.panel-header--dark button:hover:not([disabled]),
|
||||||
|
.panel-header--dark a.icon-button:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 2000000;
|
z-index: 6000000;
|
||||||
cursor: zoom-out;
|
cursor: default;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom-backdrop--visible {
|
.preview-zoom-backdrop--visible {
|
||||||
@@ -23,8 +25,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 3000000;
|
z-index: 6000001;
|
||||||
--preview-padding: 1.5vmin;
|
--preview-padding: 1.5vmin;
|
||||||
|
--header-clearance: calc(2.5rem + 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__stage--pdf {
|
.preview-zoom__stage--pdf {
|
||||||
@@ -42,6 +45,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding-top: var(--header-clearance);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__scroll--pdf {
|
.preview-zoom__scroll--pdf {
|
||||||
@@ -60,6 +65,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__scroll--native {
|
.preview-zoom__scroll--native {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -73,6 +80,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
padding-top: var(--header-clearance);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__pdf-viewer {
|
.preview-zoom__pdf-viewer {
|
||||||
@@ -94,3 +103,22 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header positioning for preview zoom overlay */
|
||||||
|
.preview-zoom-backdrop .panel-header--dark {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 6000002;
|
||||||
|
cursor: default;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 1rem 0.5rem var(--shadow-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent header clicks from closing backdrop */
|
||||||
|
.preview-zoom-backdrop .panel-header--dark * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user