import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import type { PageCallback } from 'react-pdf/dist/shared/types.js'; import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css'; pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, ).toString(); import type { CogniteFileViewerProps } from './types'; import { getViewerType } from './mimeTypes'; import { useFileResolver } from './useFileResolver'; import { useDocumentAnnotations } from './useDocumentAnnotations'; import { DocumentAnnotationOverlay } from './DocumentAnnotationOverlay'; import { useViewport, computeBaseWidth } from './useViewport'; // ============================================================================ // Sub-renderers // ============================================================================ function DefaultLoading() { return
Loading file...
; } function DefaultError({ error }: { error: Error }) { return (
Failed to load file: {error.message}
); } function DefaultUnsupported({ mimeType }: { mimeType: string | undefined }) { return (
Unsupported file type{mimeType ? `: ${mimeType}` : ''}
); } // ---------- Shared blob fetch hook ---------- function useBlobUrl(url: string) { const [blobUrl, setBlobUrl] = useState(null); const [error, setError] = useState(null); const objectUrlRef = useRef(null); useEffect(() => { let cancelled = false; // Reset state for new URL setBlobUrl(null); setError(null); // Revoke previous blob URL if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); objectUrlRef.current = null; } fetch(url) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob(); }) .then((blob) => { if (cancelled) return; const newUrl = URL.createObjectURL(blob); objectUrlRef.current = newUrl; setBlobUrl(newUrl); }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err : new Error(String(err))); }); return () => { cancelled = true; if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); objectUrlRef.current = null; } }; }, [url]); return { blobUrl, error }; } // ---------- Image ---------- interface ImageRendererProps extends Omit { url: string; } function ImageRenderer(props: ImageRendererProps) { const { url, rotation = 0, fitMode, width: explicitWidth, renderLoading, renderError, renderOverlay } = props; const { currentZoom, effectivePan, containerDims, viewportRef, cursor, handleMouseDown } = useViewport(props); const { blobUrl, error } = useBlobUrl(url); const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null); // Reset natural size when URL changes const prevUrlRef = useRef(url); if (prevUrlRef.current !== url) { prevUrlRef.current = url; setNaturalSize(null); } const handleLoad = useCallback((e: React.SyntheticEvent) => { setNaturalSize({ width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight }); }, []); if (error) return renderError ? renderError(error) : ; if (!blobUrl) return renderLoading ? renderLoading() : ; const baseWidth = computeBaseWidth(fitMode, explicitWidth, containerDims, naturalSize); const imgWidth = baseWidth ?? naturalSize?.width; // Until we know image dimensions, render hidden to measure if (!imgWidth || !naturalSize) { return (
{renderLoading ? renderLoading() : }
); } const imgHeight = imgWidth * (naturalSize.height / naturalSize.width); const isSwapped = rotation === 90 || rotation === 270; const visualW = (isSwapped ? imgHeight : imgWidth) * currentZoom; const visualH = (isSwapped ? imgWidth : imgHeight) * currentZoom; return (
1 ? 'hidden' : 'auto', cursor }} onMouseDown={handleMouseDown}>
{renderOverlay && naturalSize && ( renderOverlay({ width: visualW, height: visualH, originalWidth: naturalSize.width, originalHeight: naturalSize.height, pageNumber: 1, rotation, }) )}
); } // ---------- Text ---------- function TextRenderer({ url }: { url: string }) { const [content, setContent] = useState(null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); }) .then((text) => { if (!cancelled) setContent(text); }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err : new Error(String(err))); }); return () => { cancelled = true; }; }, [url]); if (error) return ; if (content === null) return ; return (
      {content}
    
); } // ============================================================================ // PDF Renderer (with annotation overlay) // ============================================================================ const PDF_LOAD_ERROR = new Error('Failed to load PDF'); interface PdfRendererProps extends Omit { url: string; instanceId?: { space: string; externalId: string }; } function PdfRenderer(props: PdfRendererProps) { const { url, instanceId, client, showAnnotations = true, onAnnotationClick, onAnnotationHover, renderAnnotationTooltip, page: controlledPage, onPageChange, onDocumentLoad, width, rotation = 0, fitMode, onLoadProgress, renderLoading, renderError, renderOverlay, } = props; // -- Viewport (zoom, pan, wheel, drag) -- const { currentZoom, effectivePan, containerDims, viewportRef, cursor, handleMouseDown, handlePanChange, } = useViewport(props); // -- Page state (controlled + uncontrolled) -- const [internalPage, setInternalPage] = useState(1); const currentPage = controlledPage ?? internalPage; const handlePageChange = useCallback( (newPage: number) => { setInternalPage(newPage); onPageChange?.(newPage); }, [onPageChange], ); // Reset pan on page change const handlePanChangeRef = useRef(handlePanChange); handlePanChangeRef.current = handlePanChange; useEffect(() => { handlePanChangeRef.current({ x: 0, y: 0 }); }, [currentPage]); // -- Page dimensions (for annotation overlay) -- const [pageDims, setPageDims] = useState({ width: 0, height: 0 }); const pageObserverRef = useRef(null); const pageWrapperRef = useCallback((node: HTMLDivElement | null) => { if (pageObserverRef.current) { pageObserverRef.current.disconnect(); pageObserverRef.current = null; } if (node) { const measure = () => { const w = node.clientWidth; const h = node.clientHeight; setPageDims((prev) => (prev.width === w && prev.height === h ? prev : { width: w, height: h })); }; const observer = new ResizeObserver(measure); observer.observe(node); measure(); pageObserverRef.current = observer; } }, []); useEffect(() => { return () => { pageObserverRef.current?.disconnect(); pageObserverRef.current = null; }; }, []); // -- Page natural dimensions (for fitMode='page') -- const [pageNaturalSize, setPageNaturalSize] = useState<{ width: number; height: number } | null>(null); const handlePageLoadSuccess = useCallback((page: PageCallback) => { const { originalWidth: w, originalHeight: h } = page; if (w && h) setPageNaturalSize({ width: w, height: h }); }, []); // -- Compute base width from fitMode -- const baseWidth = computeBaseWidth(fitMode, width, containerDims, pageNaturalSize); // -- Annotations -- const annotationsEnabled = showAnnotations && instanceId !== undefined; const { annotations } = useDocumentAnnotations( client, instanceId, currentPage, { enabled: annotationsEnabled }, ); // -- PDF Document callbacks -- const currentPageRef = useRef(currentPage); currentPageRef.current = currentPage; const handleLoadSuccess = useCallback( ({ numPages }: { numPages: number }) => { onDocumentLoad?.({ numPages }); if (currentPageRef.current > numPages) handlePageChange(1); }, [onDocumentLoad, handlePageChange], ); return (
1 ? 'hidden' : 'auto', cursor, height: '100%' }} onMouseDown={handleMouseDown} > } error={ renderError ? ( renderError(PDF_LOAD_ERROR) ) : ( ) } >
{annotationsEnabled && pageDims.width > 0 && annotations.length > 0 && ( )} {renderOverlay && pageDims.width > 0 && pageDims.height > 0 && pageNaturalSize && ( renderOverlay({ width: pageDims.width, height: pageDims.height, originalWidth: pageNaturalSize.width, originalHeight: pageNaturalSize.height, pageNumber: currentPage, rotation, }) )}
); } // ============================================================================ // Main Component // ============================================================================ export const CogniteFileViewer: React.FC = (props) => { const { source, client, renderLoading, renderError, renderUnsupported, className, style, } = props; const { url, mimeType, instanceId, isLoading, error, } = useFileResolver(source, client); const viewerType = getViewerType(mimeType); const rotation = props.rotation ?? 0; // -- Loading -- if (isLoading) { return (
{renderLoading ? renderLoading() : }
); } // -- Error -- if (error || !url) { return (
{renderError ? renderError(error ?? new Error('No URL resolved')) : }
); } // -- Render by type -- const renderContent = () => { switch (viewerType) { case 'pdf': return ; case 'image': return ; case 'text': return ; default: return renderUnsupported ? renderUnsupported(mimeType) : ; } }; return (
{renderContent()}
); };