init
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
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 <div style={{ padding: 16, color: '#666' }}>Loading file...</div>;
|
||||
}
|
||||
|
||||
function DefaultError({ error }: { error: Error }) {
|
||||
return (
|
||||
<div style={{ padding: 16, color: '#c00' }}>
|
||||
Failed to load file: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultUnsupported({ mimeType }: { mimeType: string | undefined }) {
|
||||
return (
|
||||
<div style={{ padding: 16, color: '#666' }}>
|
||||
Unsupported file type{mimeType ? `: ${mimeType}` : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Shared blob fetch hook ----------
|
||||
|
||||
function useBlobUrl(url: string) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(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<CogniteFileViewerProps, 'source' | 'client' | 'className' | 'style'> {
|
||||
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<HTMLImageElement>) => {
|
||||
setNaturalSize({ width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight });
|
||||
}, []);
|
||||
|
||||
if (error) return renderError ? renderError(error) : <DefaultError error={error} />;
|
||||
if (!blobUrl) return renderLoading ? renderLoading() : <DefaultLoading />;
|
||||
|
||||
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 (
|
||||
<div ref={viewportRef} style={{ overflow: 'hidden' }}>
|
||||
{renderLoading ? renderLoading() : <DefaultLoading />}
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
style={{ position: 'absolute', visibility: 'hidden', pointerEvents: 'none' }}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={viewportRef} style={{ overflow: currentZoom > 1 ? 'hidden' : 'auto', cursor }} onMouseDown={handleMouseDown}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
transform:
|
||||
effectivePan.x !== 0 || effectivePan.y !== 0
|
||||
? `translate(${effectivePan.x}px, ${effectivePan.y}px)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: visualW, height: visualH, position: 'relative' }}>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: imgWidth * currentZoom,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
}}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
{renderOverlay && naturalSize && (
|
||||
renderOverlay({
|
||||
width: visualW,
|
||||
height: visualH,
|
||||
originalWidth: naturalSize.width,
|
||||
originalHeight: naturalSize.height,
|
||||
pageNumber: 1,
|
||||
rotation,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Text ----------
|
||||
|
||||
function TextRenderer({ url }: { url: string }) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [error, setError] = useState<Error | null>(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 <DefaultError error={error} />;
|
||||
if (content === null) return <DefaultLoading />;
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 16,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PDF Renderer (with annotation overlay)
|
||||
// ============================================================================
|
||||
|
||||
const PDF_LOAD_ERROR = new Error('Failed to load PDF');
|
||||
|
||||
interface PdfRendererProps
|
||||
extends Omit<CogniteFileViewerProps, 'source' | 'className' | 'style' | 'renderUnsupported'> {
|
||||
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<ResizeObserver | null>(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 (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
style={{ overflow: currentZoom > 1 ? 'hidden' : 'auto', cursor, height: '100%' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<Document
|
||||
file={url}
|
||||
onLoadSuccess={handleLoadSuccess}
|
||||
onLoadProgress={onLoadProgress}
|
||||
loading={renderLoading ? renderLoading() : <DefaultLoading />}
|
||||
error={
|
||||
renderError ? (
|
||||
renderError(PDF_LOAD_ERROR)
|
||||
) : (
|
||||
<DefaultError error={PDF_LOAD_ERROR} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={pageWrapperRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
transform: effectivePan.x !== 0 || effectivePan.y !== 0
|
||||
? `translate(${effectivePan.x}px, ${effectivePan.y}px)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Page
|
||||
pageNumber={currentPage}
|
||||
width={baseWidth}
|
||||
scale={currentZoom}
|
||||
rotate={rotation}
|
||||
onLoadSuccess={handlePageLoadSuccess}
|
||||
/>
|
||||
{annotationsEnabled && pageDims.width > 0 && annotations.length > 0 && (
|
||||
<DocumentAnnotationOverlay
|
||||
annotations={annotations}
|
||||
containerWidth={pageDims.width}
|
||||
containerHeight={pageDims.height}
|
||||
rotation={rotation}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onAnnotationHover={onAnnotationHover}
|
||||
renderAnnotationTooltip={renderAnnotationTooltip}
|
||||
/>
|
||||
)}
|
||||
{renderOverlay && pageDims.width > 0 && pageDims.height > 0 && pageNaturalSize && (
|
||||
renderOverlay({
|
||||
width: pageDims.width,
|
||||
height: pageDims.height,
|
||||
originalWidth: pageNaturalSize.width,
|
||||
originalHeight: pageNaturalSize.height,
|
||||
pageNumber: currentPage,
|
||||
rotation,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Document>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export const CogniteFileViewer: React.FC<CogniteFileViewerProps> = (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 (
|
||||
<div className={className} style={style}>
|
||||
{renderLoading ? renderLoading() : <DefaultLoading />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Error --
|
||||
if (error || !url) {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{renderError
|
||||
? renderError(error ?? new Error('No URL resolved'))
|
||||
: <DefaultError error={error ?? new Error('No URL resolved')} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Render by type --
|
||||
const renderContent = () => {
|
||||
switch (viewerType) {
|
||||
case 'pdf':
|
||||
return <PdfRenderer {...props} url={url} instanceId={instanceId} rotation={rotation} />;
|
||||
case 'image':
|
||||
return <ImageRenderer {...props} url={url} rotation={rotation} />;
|
||||
case 'text':
|
||||
return <TextRenderer url={url} />;
|
||||
default:
|
||||
return renderUnsupported ? renderUnsupported(mimeType) : <DefaultUnsupported mimeType={mimeType} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { DocumentAnnotation, AnnotationResourceType, BoundingRect } from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Annotation colours (matches cogs.js-v10 design tokens)
|
||||
// ============================================================================
|
||||
|
||||
const ANNOTATION_COLORS: Record<
|
||||
AnnotationResourceType,
|
||||
{ stroke: string; hoverFill: string }
|
||||
> = {
|
||||
asset: {
|
||||
stroke: 'rgb(212, 106, 226)',
|
||||
hoverFill: 'rgba(212, 106, 226, 0.15)',
|
||||
},
|
||||
file: {
|
||||
stroke: 'rgb(255, 135, 70)',
|
||||
hoverFill: 'rgba(255, 135, 70, 0.15)',
|
||||
},
|
||||
timeSeries: {
|
||||
stroke: 'rgb(164, 178, 252)',
|
||||
hoverFill: 'rgba(164, 178, 252, 0.15)',
|
||||
},
|
||||
sequence: {
|
||||
stroke: 'rgb(255, 220, 127)',
|
||||
hoverFill: 'rgba(255, 220, 127, 0.15)',
|
||||
},
|
||||
event: {
|
||||
stroke: 'rgb(253, 81, 144)',
|
||||
hoverFill: 'rgba(253, 81, 144, 0.15)',
|
||||
},
|
||||
diagram: {
|
||||
stroke: 'rgb(76, 175, 80)',
|
||||
hoverFill: 'rgba(76, 175, 80, 0.15)',
|
||||
},
|
||||
unknown: {
|
||||
stroke: 'rgb(89, 89, 89)',
|
||||
hoverFill: 'rgba(89, 89, 89, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface DocumentAnnotationOverlayProps {
|
||||
/** Annotations to render (coordinates are normalised 0-1) */
|
||||
annotations: DocumentAnnotation[];
|
||||
/** Rendered page width in CSS pixels */
|
||||
containerWidth: number;
|
||||
/** Rendered page height in CSS pixels */
|
||||
containerHeight: number;
|
||||
/** Document rotation in degrees (0, 90, 180, 270) */
|
||||
rotation?: number;
|
||||
/** Called when a user clicks an annotation */
|
||||
onAnnotationClick?: (annotation: DocumentAnnotation) => void;
|
||||
/** Called when a user hovers over / leaves an annotation */
|
||||
onAnnotationHover?: (annotation: DocumentAnnotation | null) => void;
|
||||
/** Render a custom tooltip for hovered annotations. Receives the annotation and its pixel-space bounding rect. */
|
||||
renderAnnotationTooltip?: (
|
||||
annotation: DocumentAnnotation,
|
||||
rect: BoundingRect,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function getStyle(
|
||||
resourceType: AnnotationResourceType,
|
||||
isHovered: boolean,
|
||||
) {
|
||||
const colors = ANNOTATION_COLORS[resourceType] ?? ANNOTATION_COLORS.unknown;
|
||||
return {
|
||||
stroke: colors.stroke,
|
||||
fill: isHovered ? colors.hoverFill : 'none',
|
||||
strokeWidth: isHovered ? 2 : 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
function transformAnnotation(
|
||||
annotation: DocumentAnnotation,
|
||||
w: number,
|
||||
h: number,
|
||||
rotation: number,
|
||||
) {
|
||||
const { x, y, width, height } = annotation;
|
||||
switch (rotation) {
|
||||
case 90:
|
||||
return {
|
||||
x: (1 - y - height) * w,
|
||||
y: x * h,
|
||||
width: height * w,
|
||||
height: width * h,
|
||||
};
|
||||
case 180:
|
||||
return {
|
||||
x: (1 - x - width) * w,
|
||||
y: (1 - y - height) * h,
|
||||
width: width * w,
|
||||
height: height * h,
|
||||
};
|
||||
case 270:
|
||||
return {
|
||||
x: y * w,
|
||||
y: (1 - x - width) * h,
|
||||
width: height * w,
|
||||
height: width * h,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
x: x * w,
|
||||
y: y * h,
|
||||
width: width * w,
|
||||
height: height * h,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export const DocumentAnnotationOverlay: React.FC<
|
||||
DocumentAnnotationOverlayProps
|
||||
> = ({
|
||||
annotations,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
rotation = 0,
|
||||
onAnnotationClick,
|
||||
onAnnotationHover,
|
||||
renderAnnotationTooltip,
|
||||
}) => {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
if (annotations.length === 0 || containerWidth === 0 || containerHeight === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hoveredAnnotation = hoveredId
|
||||
? annotations.find((a) => a.id === hoveredId)
|
||||
: null;
|
||||
const hoveredRect = hoveredAnnotation
|
||||
? transformAnnotation(hoveredAnnotation, containerWidth, containerHeight, rotation)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible',
|
||||
zIndex: 10,
|
||||
}}
|
||||
viewBox={`0 0 ${containerWidth} ${containerHeight}`}
|
||||
>
|
||||
{annotations.map((annotation) => {
|
||||
const isHovered = hoveredId === annotation.id;
|
||||
const style = getStyle(annotation.resourceType, isHovered);
|
||||
const rect = transformAnnotation(
|
||||
annotation,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
rotation,
|
||||
);
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={annotation.id}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={style.fill}
|
||||
stroke={style.stroke}
|
||||
strokeWidth={style.strokeWidth}
|
||||
rx={1}
|
||||
ry={1}
|
||||
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
|
||||
onMouseEnter={() => {
|
||||
setHoveredId(annotation.id);
|
||||
onAnnotationHover?.(annotation);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredId(null);
|
||||
onAnnotationHover?.(null);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAnnotationClick?.(annotation);
|
||||
}}
|
||||
>
|
||||
{!renderAnnotationTooltip && annotation.text && (
|
||||
<title>{annotation.text}</title>
|
||||
)}
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{renderAnnotationTooltip && hoveredAnnotation && hoveredRect && (
|
||||
renderAnnotationTooltip(hoveredAnnotation, hoveredRect)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
export function getAnnotationColor(
|
||||
resourceType: AnnotationResourceType,
|
||||
): { stroke: string; hoverFill: string } {
|
||||
return ANNOTATION_COLORS[resourceType] ?? ANNOTATION_COLORS.unknown;
|
||||
}
|
||||
|
||||
export function getAllAnnotationColors(): Record<
|
||||
AnnotationResourceType,
|
||||
{ stroke: string; hoverFill: string }
|
||||
> {
|
||||
return ANNOTATION_COLORS;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { CogniteClient, FileInfo } from '@cognite/sdk';
|
||||
import {
|
||||
getComputedMimeType,
|
||||
isNativelySupportedMimeType,
|
||||
doesDocumentPreviewApiSupportFile,
|
||||
DocumentMimeType,
|
||||
} from './mimeTypes';
|
||||
|
||||
// ============================================================================
|
||||
// Cache
|
||||
// ============================================================================
|
||||
|
||||
/** CDF URLs expire after 60 min with extendedExpiration — refresh at 59 min. */
|
||||
const URL_CACHE_EXPIRE_MS = 59 * 60 * 1000;
|
||||
const MAX_CACHE_SIZE = 200;
|
||||
|
||||
interface CacheEntry {
|
||||
url: string;
|
||||
mimeType: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const urlCache = new Map<string, CacheEntry>();
|
||||
|
||||
/** Evict expired entries; if still over limit, drop oldest inserted. */
|
||||
function evictStaleEntries(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of urlCache) {
|
||||
if (entry.expiresAt <= now) urlCache.delete(key);
|
||||
}
|
||||
if (urlCache.size > MAX_CACHE_SIZE) {
|
||||
for (const key of Array.from(urlCache.keys()).slice(0, urlCache.size - MAX_CACHE_SIZE)) {
|
||||
urlCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearFileCache(fileId: number, project?: string): void {
|
||||
const prefix = project ? `${project}:` : '';
|
||||
urlCache.delete(`${prefix}${fileId}`);
|
||||
}
|
||||
|
||||
export function clearAllFileCache(): void {
|
||||
urlCache.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Download URL helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get download URL with extended expiration (59 min instead of default ~30 min).
|
||||
* The JS SDK doesn't expose `extendedExpiration`, so we call the API directly.
|
||||
*/
|
||||
async function getDownloadUrlExtended(
|
||||
client: CogniteClient,
|
||||
fileId: number,
|
||||
): Promise<string> {
|
||||
const result = await client.post<{ items: Array<{ downloadUrl: string }> }>(
|
||||
`/api/v1/projects/${client.project}/files/downloadlink`,
|
||||
{
|
||||
data: { items: [{ id: fileId }] },
|
||||
params: { extendedExpiration: true },
|
||||
},
|
||||
);
|
||||
const downloadUrl = result.data.items[0]?.downloadUrl;
|
||||
if (!downloadUrl) throw new Error(`No download URL for file ${fileId}`);
|
||||
return downloadUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a temporary PDF link via the Document Preview API.
|
||||
* Converts Office documents to PDF.
|
||||
*/
|
||||
async function getPdfTemporaryLink(
|
||||
client: CogniteClient,
|
||||
fileId: number,
|
||||
): Promise<string> {
|
||||
const response = await client.documents.preview.pdfTemporaryLink(fileId);
|
||||
return response.temporaryLink;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main resolution function
|
||||
// ============================================================================
|
||||
|
||||
export interface ResolvedFileConfig {
|
||||
url: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CDF file to a download URL and effective MIME type.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Natively supported (images, PDF, text) → direct download with extended expiry
|
||||
* 2. Office documents → PDF conversion via Document Preview API
|
||||
* 3. Otherwise → throws
|
||||
*
|
||||
* Results are cached for 59 minutes.
|
||||
*/
|
||||
export async function resolveFileDownloadConfig(
|
||||
client: CogniteClient,
|
||||
file: FileInfo,
|
||||
): Promise<ResolvedFileConfig> {
|
||||
const cacheKey = `${client.project}:${file.id}`;
|
||||
const now = Date.now();
|
||||
const cached = urlCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return { url: cached.url, mimeType: cached.mimeType };
|
||||
}
|
||||
|
||||
const computedMimeType = getComputedMimeType(file);
|
||||
|
||||
let resolved: ResolvedFileConfig;
|
||||
|
||||
if (computedMimeType && isNativelySupportedMimeType(computedMimeType)) {
|
||||
const url = await getDownloadUrlExtended(client, file.id);
|
||||
resolved = { url, mimeType: computedMimeType };
|
||||
} else if (doesDocumentPreviewApiSupportFile(file)) {
|
||||
const url = await getPdfTemporaryLink(client, file.id);
|
||||
resolved = { url, mimeType: DocumentMimeType.PDF };
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported file type (id: ${file.id}, name: ${file.name}, mimeType: ${file.mimeType})`,
|
||||
);
|
||||
}
|
||||
|
||||
urlCache.set(cacheKey, { ...resolved, expiresAt: now + URL_CACHE_EXPIRE_MS });
|
||||
evictStaleEntries();
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Component
|
||||
export { CogniteFileViewer } from './CogniteFileViewer';
|
||||
|
||||
// Annotation overlay (for custom compositions)
|
||||
export {
|
||||
DocumentAnnotationOverlay,
|
||||
getAnnotationColor,
|
||||
getAllAnnotationColors,
|
||||
} from './DocumentAnnotationOverlay';
|
||||
export type { DocumentAnnotationOverlayProps } from './DocumentAnnotationOverlay';
|
||||
|
||||
// Hooks (for advanced / custom usage)
|
||||
export { useFileResolver } from './useFileResolver';
|
||||
export { useDocumentAnnotations, clearAnnotationCache } from './useDocumentAnnotations';
|
||||
|
||||
// File resolution utilities
|
||||
export { resolveFileDownloadConfig, clearFileCache, clearAllFileCache } from './fileResolution';
|
||||
|
||||
// MIME type utilities
|
||||
export {
|
||||
getViewerType,
|
||||
getComputedMimeType,
|
||||
inferMimeTypeFromUrl,
|
||||
isNativelySupportedMimeType,
|
||||
doesDocumentPreviewApiSupportFile,
|
||||
} from './mimeTypes';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
FileSource,
|
||||
FileViewerType,
|
||||
DocumentAnnotation,
|
||||
AnnotationResourceType,
|
||||
BoundingRect,
|
||||
OverlayRenderInfo,
|
||||
ResolvedFile,
|
||||
UseFileResolverResult,
|
||||
UseDocumentAnnotationsResult,
|
||||
CogniteFileViewerProps,
|
||||
} from './types';
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { FileViewerType } from './types';
|
||||
|
||||
// ============================================================================
|
||||
// MIME Type Constants
|
||||
// ============================================================================
|
||||
|
||||
export const DocumentMimeType = {
|
||||
PDF: 'application/pdf',
|
||||
} as const;
|
||||
|
||||
export const ImageMimeType = {
|
||||
JPEG: 'image/jpeg',
|
||||
PNG: 'image/png',
|
||||
SVG: 'image/svg+xml',
|
||||
TIFF: 'image/tiff',
|
||||
WEBP: 'image/webp',
|
||||
} as const;
|
||||
|
||||
export const TextMimeType = {
|
||||
TXT: 'text/plain',
|
||||
CSV: 'text/csv',
|
||||
JSON: 'application/json',
|
||||
} as const;
|
||||
|
||||
const NativelySupportedMimeTypes = {
|
||||
...ImageMimeType,
|
||||
...DocumentMimeType,
|
||||
...TextMimeType,
|
||||
} as const;
|
||||
|
||||
type NativelySupportedMimeType =
|
||||
(typeof NativelySupportedMimeTypes)[keyof typeof NativelySupportedMimeTypes];
|
||||
|
||||
// Pre-computed Sets for O(1) lookups (these objects are `as const`, never mutated)
|
||||
const nativelySupportedSet = new Set<string>(Object.values(NativelySupportedMimeTypes));
|
||||
const documentMimeSet = new Set<string>(Object.values(DocumentMimeType));
|
||||
const imageMimeSet = new Set<string>(Object.values(ImageMimeType));
|
||||
const textMimeSet = new Set<string>(Object.values(TextMimeType));
|
||||
// ============================================================================
|
||||
// Document Preview API Support (Office → PDF conversion)
|
||||
// Source: https://github.com/cognitedata/document-preview
|
||||
// ============================================================================
|
||||
|
||||
const documentPreviewMimeTypes = [
|
||||
// Word
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
|
||||
'application/vnd.ms-word.document.macroEnabled.12',
|
||||
'application/vnd.ms-word.template.macroEnabled.12',
|
||||
'application/rtf',
|
||||
'application/vnd.oasis.opendocument.text',
|
||||
'application/vnd.oasis.opendocument.text-template',
|
||||
// PowerPoint
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.template',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
||||
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.template.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
|
||||
'application/vnd.oasis.opendocument.presentation',
|
||||
'application/vnd.oasis.opendocument.presentation-template',
|
||||
// Excel
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'application/vnd.ms-excel.template.macroEnabled.12',
|
||||
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
||||
'application/vnd.apple.numbers',
|
||||
'text/tab-separated-values',
|
||||
'application/vnd.oasis.opendocument.spreadsheet',
|
||||
];
|
||||
|
||||
const documentPreviewExtensions = new Set([
|
||||
'doc', 'dot', 'docx', 'dotx', 'docm', 'dotm', 'rtf', 'odt', 'ott',
|
||||
'ppt', 'pot', 'pps', 'pptx', 'potx', 'ppsx', 'pptm', 'potm', 'ppsm', 'odp', 'otp',
|
||||
'xls', 'xlt', 'xlsx', 'xltx', 'xlsm', 'xltm', 'xlsb', 'numbers', 'tsv', 'ods',
|
||||
]);
|
||||
|
||||
const documentPreviewMimeSet = new Set(documentPreviewMimeTypes);
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function getFileExtension(value: string): string {
|
||||
const clean = value.split('#')[0].split('?')[0];
|
||||
const filename = clean.split('/').pop() ?? '';
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot <= 0 || lastDot === filename.length - 1) return '';
|
||||
return filename.slice(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function canonicaliseMimeType(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case 'image/jpg':
|
||||
return ImageMimeType.JPEG;
|
||||
case 'image/tif':
|
||||
return ImageMimeType.TIFF;
|
||||
case 'image/svg':
|
||||
return ImageMimeType.SVG;
|
||||
case 'application/txt':
|
||||
return TextMimeType.TXT;
|
||||
default:
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
export function isNativelySupportedMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
): mimeType is NativelySupportedMimeType {
|
||||
if (!mimeType) return false;
|
||||
return nativelySupportedSet.has(mimeType);
|
||||
}
|
||||
|
||||
export function doesDocumentPreviewApiSupportFile(file: {
|
||||
mimeType?: string | null;
|
||||
name?: string | null;
|
||||
}): boolean {
|
||||
if (file.mimeType && documentPreviewMimeSet.has(file.mimeType)) return true;
|
||||
if (file.name && documentPreviewExtensions.has(getFileExtension(file.name))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const extensionToMimeType: Record<string, string> = {
|
||||
pdf: DocumentMimeType.PDF,
|
||||
jpg: ImageMimeType.JPEG,
|
||||
jpeg: ImageMimeType.JPEG,
|
||||
png: ImageMimeType.PNG,
|
||||
svg: ImageMimeType.SVG,
|
||||
tif: ImageMimeType.TIFF,
|
||||
tiff: ImageMimeType.TIFF,
|
||||
webp: ImageMimeType.WEBP,
|
||||
txt: TextMimeType.TXT,
|
||||
csv: TextMimeType.CSV,
|
||||
json: TextMimeType.JSON,
|
||||
};
|
||||
|
||||
export function inferMimeTypeFromUrl(urlOrName: string): string | undefined {
|
||||
return extensionToMimeType[getFileExtension(urlOrName)];
|
||||
}
|
||||
|
||||
export function getComputedMimeType(file: {
|
||||
mimeType?: string | null;
|
||||
name?: string | null;
|
||||
}): string | undefined {
|
||||
if (file.mimeType) return canonicaliseMimeType(file.mimeType);
|
||||
if (file.name) return inferMimeTypeFromUrl(file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getViewerType(mimeType: string | undefined): FileViewerType {
|
||||
if (!mimeType) return 'unsupported';
|
||||
|
||||
const canonical = canonicaliseMimeType(mimeType);
|
||||
|
||||
if (documentMimeSet.has(canonical)) return 'pdf';
|
||||
if (imageMimeSet.has(canonical)) return 'image';
|
||||
if (textMimeSet.has(canonical)) return 'text';
|
||||
|
||||
// Office documents get converted to PDF
|
||||
if (documentPreviewMimeSet.has(canonical)) return 'pdf';
|
||||
|
||||
return 'unsupported';
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type React from 'react';
|
||||
import type { CogniteClient, FileInfo } from '@cognite/sdk';
|
||||
|
||||
// ============================================================================
|
||||
// File Source (discriminated union)
|
||||
// ============================================================================
|
||||
|
||||
export type FileSource =
|
||||
| { type: 'instanceId'; space: string; externalId: string }
|
||||
| { type: 'url'; url: string; mimeType?: string }
|
||||
| { type: 'internalId'; id: number };
|
||||
|
||||
// ============================================================================
|
||||
// Viewer Types
|
||||
// ============================================================================
|
||||
|
||||
export type FileViewerType = 'pdf' | 'image' | 'text' | 'unsupported';
|
||||
|
||||
// ============================================================================
|
||||
// Annotations
|
||||
// ============================================================================
|
||||
|
||||
export type AnnotationResourceType =
|
||||
| 'asset'
|
||||
| 'file'
|
||||
| 'timeSeries'
|
||||
| 'sequence'
|
||||
| 'event'
|
||||
| 'diagram'
|
||||
| 'unknown';
|
||||
|
||||
export interface DocumentAnnotation {
|
||||
id: string;
|
||||
/** Normalized bounding box (0-1 range relative to page) */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** 1-indexed page number */
|
||||
page: number;
|
||||
resourceType: AnnotationResourceType;
|
||||
linkedResource?: { space: string; externalId: string };
|
||||
/** Text content (e.g. tag name) */
|
||||
text?: string;
|
||||
annotationType: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resolved File
|
||||
// ============================================================================
|
||||
|
||||
export interface ResolvedFile {
|
||||
url: string;
|
||||
mimeType: string;
|
||||
fileInfo?: FileInfo;
|
||||
instanceId?: { space: string; externalId: string };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Results
|
||||
// ============================================================================
|
||||
|
||||
export interface UseFileResolverResult extends Partial<ResolvedFile> {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface UseDocumentAnnotationsResult {
|
||||
annotations: DocumentAnnotation[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Geometry
|
||||
// ============================================================================
|
||||
|
||||
export interface BoundingRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Overlay
|
||||
// ============================================================================
|
||||
|
||||
export interface OverlayRenderInfo {
|
||||
/** Rendered page width in CSS pixels (after zoom/scale) */
|
||||
width: number;
|
||||
/** Rendered page height in CSS pixels (after zoom/scale) */
|
||||
height: number;
|
||||
/** Original page width before zoom (PDF points or image natural pixels) */
|
||||
originalWidth: number;
|
||||
/** Original page height before zoom (PDF points or image natural pixels) */
|
||||
originalHeight: number;
|
||||
/** Current page number (1-indexed) */
|
||||
pageNumber: number;
|
||||
/** Current rotation in degrees */
|
||||
rotation: 0 | 90 | 180 | 270;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export interface CogniteFileViewerProps {
|
||||
/** File source — instance ID, direct URL, or CDF internal ID */
|
||||
source: FileSource;
|
||||
/** CogniteClient instance (required for instanceId and internalId sources) */
|
||||
client?: CogniteClient;
|
||||
|
||||
// -- Annotations --
|
||||
/** Show diagram annotations overlay on PDFs (default: true) */
|
||||
showAnnotations?: boolean;
|
||||
/** Called when a user clicks an annotation */
|
||||
onAnnotationClick?: (annotation: DocumentAnnotation) => void;
|
||||
/** Called when a user hovers over / leaves an annotation */
|
||||
onAnnotationHover?: (annotation: DocumentAnnotation | null) => void;
|
||||
/** Render a custom tooltip when hovering an annotation. Receives the annotation and its pixel-space bounding rect. */
|
||||
renderAnnotationTooltip?: (
|
||||
annotation: DocumentAnnotation,
|
||||
rect: BoundingRect,
|
||||
) => React.ReactNode;
|
||||
|
||||
// -- PDF controls --
|
||||
/** Current page (1-indexed). Uncontrolled if omitted. */
|
||||
page?: number;
|
||||
/** Called when the displayed page changes */
|
||||
onPageChange?: (page: number) => void;
|
||||
/** Called once the PDF document is loaded */
|
||||
onDocumentLoad?: (info: { numPages: number }) => void;
|
||||
/** Desired page width in pixels */
|
||||
width?: number;
|
||||
/** Page rotation in degrees */
|
||||
rotation?: 0 | 90 | 180 | 270;
|
||||
|
||||
// -- Zoom & Pan --
|
||||
/** Current zoom level (1 = 100%). Supports controlled + uncontrolled. */
|
||||
zoom?: number;
|
||||
/** Called when zoom changes (Ctrl/Cmd+wheel or pinch) */
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
/** Minimum zoom level (default: 0.25) */
|
||||
minZoom?: number;
|
||||
/** Maximum zoom level (default: 5) */
|
||||
maxZoom?: number;
|
||||
/** Pan offset in pixels. Supports controlled + uncontrolled. Resets on page change. */
|
||||
panOffset?: { x: number; y: number };
|
||||
/** Called when pan changes (drag when zoomed in) */
|
||||
onPanChange?: (offset: { x: number; y: number }) => void;
|
||||
|
||||
// -- Fit & Progress --
|
||||
/** Auto-fit mode: 'width' fits page to container width, 'page' fits entire page in container */
|
||||
fitMode?: 'width' | 'page';
|
||||
/** Called during PDF download with progress info */
|
||||
onLoadProgress?: (progress: { loaded: number; total: number }) => void;
|
||||
|
||||
// -- Custom overlay --
|
||||
/**
|
||||
* Render custom content (e.g. SVG paths, highlights, drawings) on top of the page.
|
||||
* The overlay is absolutely positioned over the rendered page.
|
||||
*
|
||||
* Provides both rendered dimensions and original (unscaled) page dimensions,
|
||||
* so consumers can set up an SVG `viewBox` in the original coordinate space:
|
||||
* ```tsx
|
||||
* renderOverlay={({ width, height, originalWidth, originalHeight }) => (
|
||||
* <svg width={width} height={height}
|
||||
* viewBox={`0 0 ${originalWidth} ${originalHeight}`}
|
||||
* preserveAspectRatio="none"
|
||||
* style={{ pointerEvents: 'all' }}>
|
||||
* <path d="..." />
|
||||
* </svg>
|
||||
* )}
|
||||
* ```
|
||||
*/
|
||||
renderOverlay?: (info: OverlayRenderInfo) => React.ReactNode;
|
||||
|
||||
// -- Customisation --
|
||||
/** Override the default loading indicator */
|
||||
renderLoading?: () => React.ReactNode;
|
||||
/** Override the default error view */
|
||||
renderError?: (error: Error) => React.ReactNode;
|
||||
/** Override the default "unsupported file" view */
|
||||
renderUnsupported?: (mimeType: string | undefined) => React.ReactNode;
|
||||
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import type { CogniteClient, EdgeDefinition } from '@cognite/sdk';
|
||||
import type {
|
||||
DocumentAnnotation,
|
||||
AnnotationResourceType,
|
||||
UseDocumentAnnotationsResult,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// CDM constants
|
||||
// ============================================================================
|
||||
|
||||
const CDM_SPACE = 'cdf_cdm';
|
||||
const CDM_VERSION = 'v1';
|
||||
const DIAGRAM_ANNOTATION_VIEW = 'CogniteDiagramAnnotation';
|
||||
const QUERY_LIMIT = 10_000;
|
||||
|
||||
// ============================================================================
|
||||
// Cache — stores ALL annotations for a file, filtered by page at read time
|
||||
// ============================================================================
|
||||
|
||||
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_CACHE_SIZE = 50;
|
||||
|
||||
interface CacheEntry {
|
||||
data: DocumentAnnotation[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const annotationCache = new Map<string, CacheEntry>();
|
||||
|
||||
/** Cache key scoped by project + file instance. */
|
||||
function fileCacheKey(project: string, space: string, externalId: string): string {
|
||||
return JSON.stringify([project, space, externalId]);
|
||||
}
|
||||
|
||||
function evictStaleAnnotations(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of annotationCache) {
|
||||
if (now - entry.timestamp > STALE_TIME) annotationCache.delete(key);
|
||||
}
|
||||
if (annotationCache.size > MAX_CACHE_SIZE) {
|
||||
for (const key of Array.from(annotationCache.keys()).slice(0, annotationCache.size - MAX_CACHE_SIZE)) {
|
||||
annotationCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAnnotationCache(): void {
|
||||
annotationCache.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
interface CdmAnnotationProps {
|
||||
status?: string;
|
||||
startNodeText?: string;
|
||||
startNodeYMax?: number;
|
||||
startNodeYMin?: number;
|
||||
startNodeXMax?: number;
|
||||
startNodeXMin?: number;
|
||||
startNodePageNumber?: number;
|
||||
}
|
||||
|
||||
function getResourceType(annotationType: string): AnnotationResourceType {
|
||||
const lower = annotationType.toLowerCase();
|
||||
if (lower.includes('asset')) return 'asset';
|
||||
if (lower.includes('file')) return 'file';
|
||||
if (lower.includes('timeseries') || lower.includes('time_series'))
|
||||
return 'timeSeries';
|
||||
if (lower.includes('sequence')) return 'sequence';
|
||||
if (lower.includes('event')) return 'event';
|
||||
if (lower.includes('diagram')) return 'diagram';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fetcher — fetches ALL annotations for a file (not per-page)
|
||||
// ============================================================================
|
||||
|
||||
async function fetchAllAnnotations(
|
||||
client: CogniteClient,
|
||||
space: string,
|
||||
externalId: string,
|
||||
): Promise<DocumentAnnotation[]> {
|
||||
const containerId = `${space}:${externalId}`;
|
||||
const propPath = `${DIAGRAM_ANNOTATION_VIEW}/${CDM_VERSION}`;
|
||||
|
||||
const allEdges: EdgeDefinition[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await client.instances.query({
|
||||
with: {
|
||||
files: {
|
||||
nodes: {
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
equals: {
|
||||
property: ['node', 'externalId'],
|
||||
value: externalId,
|
||||
},
|
||||
},
|
||||
{
|
||||
equals: {
|
||||
property: ['node', 'space'],
|
||||
value: space,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
edges: {
|
||||
from: 'files',
|
||||
direction: 'outwards',
|
||||
},
|
||||
limit: QUERY_LIMIT,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
annotations: {
|
||||
sources: [
|
||||
{
|
||||
source: {
|
||||
externalId: DIAGRAM_ANNOTATION_VIEW,
|
||||
space: CDM_SPACE,
|
||||
type: 'view' as const,
|
||||
version: CDM_VERSION,
|
||||
},
|
||||
properties: [
|
||||
'status',
|
||||
'startNodeText',
|
||||
'startNodeYMax',
|
||||
'startNodeYMin',
|
||||
'startNodeXMax',
|
||||
'startNodeXMin',
|
||||
'startNodePageNumber',
|
||||
],
|
||||
},
|
||||
],
|
||||
limit: QUERY_LIMIT,
|
||||
},
|
||||
},
|
||||
cursors: cursor ? { annotations: cursor } : undefined,
|
||||
});
|
||||
|
||||
const edges = (response.items?.annotations ?? []).filter(
|
||||
(a) => a.instanceType === 'edge',
|
||||
);
|
||||
allEdges.push(...edges);
|
||||
|
||||
cursor =
|
||||
edges.length < QUERY_LIMIT
|
||||
? undefined
|
||||
: response.nextCursor?.annotations;
|
||||
} while (cursor);
|
||||
|
||||
return allEdges.flatMap((edge) => {
|
||||
const props: CdmAnnotationProps | undefined =
|
||||
edge.properties?.[CDM_SPACE]?.[propPath];
|
||||
if (!props) return [];
|
||||
if (props.status === 'Rejected') return [];
|
||||
|
||||
const xMin = Number(props.startNodeXMin ?? 0);
|
||||
const xMax = Number(props.startNodeXMax ?? 0);
|
||||
const yMin = Number(props.startNodeYMin ?? 0);
|
||||
const yMax = Number(props.startNodeYMax ?? 0);
|
||||
|
||||
const annotationType =
|
||||
edge.type?.externalId ?? 'diagrams.AssetLink';
|
||||
|
||||
const annotation: DocumentAnnotation = {
|
||||
id: `${containerId}-${edge.space}-${edge.externalId}`,
|
||||
x: Math.min(xMin, xMax),
|
||||
y: Math.min(yMin, yMax),
|
||||
width: Math.abs(xMax - xMin),
|
||||
height: Math.abs(yMax - yMin),
|
||||
page: Number(props.startNodePageNumber ?? 1),
|
||||
resourceType: getResourceType(annotationType),
|
||||
linkedResource: edge.endNode
|
||||
? { space: edge.endNode.space, externalId: edge.endNode.externalId }
|
||||
: undefined,
|
||||
text: props.startNodeText ?? undefined,
|
||||
annotationType,
|
||||
};
|
||||
return [annotation];
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
interface AnnotationState {
|
||||
allAnnotations: DocumentAnnotation[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AnnotationState = {
|
||||
allAnnotations: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useDocumentAnnotations(
|
||||
client: CogniteClient | undefined,
|
||||
instanceId: { space: string; externalId: string } | undefined,
|
||||
currentPage: number = 1,
|
||||
options?: { enabled?: boolean },
|
||||
): UseDocumentAnnotationsResult {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [state, setState] = useState<AnnotationState>(INITIAL_STATE);
|
||||
const cancelRef = useRef(0);
|
||||
|
||||
const space = instanceId?.space;
|
||||
const extId = instanceId?.externalId;
|
||||
const project = client?.project;
|
||||
|
||||
// Fetch all annotations for the file (not per-page)
|
||||
useEffect(() => {
|
||||
if (!enabled || !client || !space || !extId || !project) {
|
||||
setState(INITIAL_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++cancelRef.current;
|
||||
const cancelled = () => id !== cancelRef.current;
|
||||
|
||||
const key = fileCacheKey(project, space, extId);
|
||||
const cached = annotationCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < STALE_TIME) {
|
||||
setState({ allAnnotations: cached.data, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
fetchAllAnnotations(client, space, extId)
|
||||
.then((data) => {
|
||||
if (cancelled()) return;
|
||||
annotationCache.set(key, { data, timestamp: Date.now() });
|
||||
evictStaleAnnotations();
|
||||
setState({ allAnnotations: data, isLoading: false, error: null });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled()) return;
|
||||
setState({
|
||||
allAnnotations: [],
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
});
|
||||
}, [client, project, space, extId, enabled]);
|
||||
|
||||
// Filter by current page (cheap client-side filter on cached data)
|
||||
const annotations = useMemo(
|
||||
() => state.allAnnotations.filter((a) => a.page === currentPage),
|
||||
[state.allAnnotations, currentPage],
|
||||
);
|
||||
|
||||
return { annotations, isLoading: state.isLoading, error: state.error };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { CogniteClient } from '@cognite/sdk';
|
||||
import type { FileSource, UseFileResolverResult } from './types';
|
||||
import { inferMimeTypeFromUrl } from './mimeTypes';
|
||||
import { resolveFileDownloadConfig } from './fileResolution';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function getSourceKey(source: FileSource): string {
|
||||
switch (source.type) {
|
||||
case 'instanceId':
|
||||
return `inst:${source.space}/${source.externalId}`;
|
||||
case 'internalId':
|
||||
return `id:${source.id}`;
|
||||
case 'url':
|
||||
return `url:${source.url}\0${source.mimeType ?? ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
const INITIAL: UseFileResolverResult = {
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolves a {@link FileSource} to a download URL and MIME type.
|
||||
*
|
||||
* - `url` sources are returned directly (no client needed).
|
||||
* - `internalId` and `instanceId` sources use the CogniteClient to fetch
|
||||
* metadata and resolve a download URL (with caching).
|
||||
*/
|
||||
export function useFileResolver(
|
||||
source: FileSource,
|
||||
client?: CogniteClient,
|
||||
): UseFileResolverResult {
|
||||
const [result, setResult] = useState<UseFileResolverResult>(INITIAL);
|
||||
const sourceKey = getSourceKey(source);
|
||||
const cancelRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = ++cancelRef.current;
|
||||
const cancelled = () => id !== cancelRef.current;
|
||||
|
||||
async function resolve() {
|
||||
setResult(INITIAL);
|
||||
|
||||
try {
|
||||
// ----- URL source: no client needed -----
|
||||
if (source.type === 'url') {
|
||||
const mimeType = source.mimeType ?? inferMimeTypeFromUrl(source.url);
|
||||
setResult({
|
||||
url: source.url,
|
||||
mimeType: mimeType ?? '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- CDF sources: client is required -----
|
||||
if (!client) {
|
||||
throw new Error(
|
||||
'CogniteClient is required for instanceId and internalId sources',
|
||||
);
|
||||
}
|
||||
|
||||
// Build the lookup identifier the SDK expects
|
||||
const idParam =
|
||||
source.type === 'internalId'
|
||||
? { id: source.id }
|
||||
: {
|
||||
instanceId: {
|
||||
space: source.space,
|
||||
externalId: source.externalId,
|
||||
},
|
||||
};
|
||||
|
||||
const [fileInfo] = await client.files.retrieve([idParam]);
|
||||
if (cancelled()) return;
|
||||
|
||||
const resolved = await resolveFileDownloadConfig(client, fileInfo);
|
||||
if (cancelled()) return;
|
||||
|
||||
// Derive instanceId — prefer the one returned by the API,
|
||||
// fall back to what the caller passed for instanceId sources.
|
||||
const instanceId = fileInfo.instanceId
|
||||
? {
|
||||
space: fileInfo.instanceId.space,
|
||||
externalId: fileInfo.instanceId.externalId,
|
||||
}
|
||||
: source.type === 'instanceId'
|
||||
? { space: source.space, externalId: source.externalId }
|
||||
: undefined;
|
||||
|
||||
setResult({
|
||||
url: resolved.url,
|
||||
mimeType: resolved.mimeType,
|
||||
fileInfo,
|
||||
instanceId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled()) return;
|
||||
setResult({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
}, [sourceKey, client]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
const ZERO_PAN = { x: 0, y: 0 };
|
||||
|
||||
export interface ViewportOptions {
|
||||
zoom?: number;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
panOffset?: { x: number; y: number };
|
||||
onPanChange?: (offset: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
/** Get distance between two touch points. */
|
||||
function getTouchDistance(t1: Touch, t2: Touch): number {
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/** Get midpoint between two touch points. */
|
||||
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
|
||||
return {
|
||||
x: (t1.clientX + t2.clientX) / 2,
|
||||
y: (t1.clientY + t2.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function useViewport(options: ViewportOptions) {
|
||||
const {
|
||||
zoom: controlledZoom,
|
||||
onZoomChange,
|
||||
minZoom = 0.25,
|
||||
maxZoom = 5,
|
||||
panOffset: controlledPan,
|
||||
onPanChange,
|
||||
} = options;
|
||||
|
||||
// -- Zoom state (controlled + uncontrolled) --
|
||||
const [internalZoom, setInternalZoom] = useState(1);
|
||||
const currentZoom = controlledZoom ?? internalZoom;
|
||||
|
||||
const clampZoom = useCallback(
|
||||
(z: number) => Math.min(maxZoom, Math.max(minZoom, z)),
|
||||
[minZoom, maxZoom],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(newZoom: number) => {
|
||||
const clamped = clampZoom(newZoom);
|
||||
setInternalZoom(clamped);
|
||||
onZoomChange?.(clamped);
|
||||
},
|
||||
[onZoomChange, clampZoom],
|
||||
);
|
||||
|
||||
// -- Pan state (controlled + uncontrolled) --
|
||||
const [internalPan, setInternalPan] = useState(ZERO_PAN);
|
||||
const currentPan = controlledPan ?? internalPan;
|
||||
|
||||
const handlePanChange = useCallback(
|
||||
(offset: { x: number; y: number }) => {
|
||||
setInternalPan(offset);
|
||||
onPanChange?.(offset);
|
||||
},
|
||||
[onPanChange],
|
||||
);
|
||||
|
||||
const effectivePan = currentZoom <= 1 ? ZERO_PAN : currentPan;
|
||||
|
||||
// -- Stable refs for event handlers --
|
||||
const currentZoomRef = useRef(currentZoom);
|
||||
currentZoomRef.current = currentZoom;
|
||||
const currentPanRef = useRef(currentPan);
|
||||
currentPanRef.current = currentPan;
|
||||
const clampZoomRef = useRef(clampZoom);
|
||||
clampZoomRef.current = clampZoom;
|
||||
const handleZoomChangeRef = useRef(handleZoomChange);
|
||||
handleZoomChangeRef.current = handleZoomChange;
|
||||
const handlePanChangeRef = useRef(handlePanChange);
|
||||
handlePanChangeRef.current = handlePanChange;
|
||||
|
||||
// -- Container dimensions --
|
||||
const [containerDims, setContainerDims] = useState({ width: 0, height: 0 });
|
||||
const viewportObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const eventCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// -- Touch gesture state (stored in ref to avoid re-renders during gesture) --
|
||||
const touchStateRef = useRef<{
|
||||
initialDistance: number;
|
||||
initialZoom: number;
|
||||
initialPan: { x: number; y: number };
|
||||
initialCenter: { x: number; y: number };
|
||||
initialRect: DOMRect;
|
||||
} | null>(null);
|
||||
|
||||
const viewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
eventCleanupRef.current?.();
|
||||
eventCleanupRef.current = null;
|
||||
viewportObserverRef.current?.disconnect();
|
||||
viewportObserverRef.current = null;
|
||||
|
||||
if (node) {
|
||||
const measure = () => {
|
||||
const w = node.clientWidth;
|
||||
const h = node.clientHeight;
|
||||
setContainerDims((prev) =>
|
||||
prev.width === w && prev.height === h ? prev : { width: w, height: h },
|
||||
);
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node);
|
||||
measure();
|
||||
viewportObserverRef.current = observer;
|
||||
|
||||
// Ctrl/Cmd + wheel → zoom toward cursor
|
||||
const wheelHandler = (e: WheelEvent) => {
|
||||
// Ctrl/Cmd + wheel → zoom toward cursor
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const oldZoom = currentZoomRef.current;
|
||||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = clampZoomRef.current(oldZoom * factor);
|
||||
if (newZoom === oldZoom) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const pan = currentPanRef.current;
|
||||
const ratio = newZoom / oldZoom;
|
||||
handleZoomChangeRef.current(newZoom);
|
||||
handlePanChangeRef.current({
|
||||
x: cx - (cx - pan.x) * ratio,
|
||||
y: cy - (cy - pan.y) * ratio,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Wheel/trackpad scroll → pan when zoomed in
|
||||
if (currentZoomRef.current > 1) {
|
||||
e.preventDefault();
|
||||
const pan = currentPanRef.current;
|
||||
handlePanChangeRef.current({
|
||||
x: pan.x - e.deltaX,
|
||||
y: pan.y - e.deltaY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Touch: pinch-to-zoom + two-finger pan
|
||||
const touchStartHandler = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 2) return;
|
||||
e.preventDefault();
|
||||
const t1 = e.touches[0];
|
||||
const t2 = e.touches[1];
|
||||
touchStateRef.current = {
|
||||
initialDistance: getTouchDistance(t1, t2),
|
||||
initialZoom: currentZoomRef.current,
|
||||
initialPan: { ...currentPanRef.current },
|
||||
initialCenter: getTouchCenter(t1, t2),
|
||||
initialRect: node.getBoundingClientRect(),
|
||||
};
|
||||
};
|
||||
|
||||
const touchMoveHandler = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 2 || !touchStateRef.current) return;
|
||||
e.preventDefault();
|
||||
const t1 = e.touches[0];
|
||||
const t2 = e.touches[1];
|
||||
const { initialDistance, initialZoom, initialPan, initialCenter, initialRect } = touchStateRef.current;
|
||||
|
||||
// Zoom
|
||||
const currentDistance = getTouchDistance(t1, t2);
|
||||
const scale = currentDistance / initialDistance;
|
||||
const newZoom = clampZoomRef.current(initialZoom * scale);
|
||||
handleZoomChangeRef.current(newZoom);
|
||||
|
||||
// Pan toward pinch center (use cached rect to avoid layout thrashing)
|
||||
const center = getTouchCenter(t1, t2);
|
||||
const cx = initialCenter.x - initialRect.left;
|
||||
const cy = initialCenter.y - initialRect.top;
|
||||
const ratio = newZoom / initialZoom;
|
||||
handlePanChangeRef.current({
|
||||
x: cx - (cx - initialPan.x) * ratio + (center.x - initialCenter.x),
|
||||
y: cy - (cy - initialPan.y) * ratio + (center.y - initialCenter.y),
|
||||
});
|
||||
};
|
||||
|
||||
const touchEndHandler = () => {
|
||||
touchStateRef.current = null;
|
||||
};
|
||||
|
||||
node.addEventListener('wheel', wheelHandler, { passive: false });
|
||||
node.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||||
node.addEventListener('touchmove', touchMoveHandler, { passive: false });
|
||||
node.addEventListener('touchend', touchEndHandler);
|
||||
node.addEventListener('touchcancel', touchEndHandler);
|
||||
|
||||
eventCleanupRef.current = () => {
|
||||
node.removeEventListener('wheel', wheelHandler);
|
||||
node.removeEventListener('touchstart', touchStartHandler);
|
||||
node.removeEventListener('touchmove', touchMoveHandler);
|
||||
node.removeEventListener('touchend', touchEndHandler);
|
||||
node.removeEventListener('touchcancel', touchEndHandler);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
eventCleanupRef.current?.();
|
||||
viewportObserverRef.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// -- Drag to pan (when zoomed in) --
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStart = useRef(ZERO_PAN);
|
||||
const panStart = useRef(ZERO_PAN);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (currentZoomRef.current <= 1) return;
|
||||
if (e.button !== 1) return; // middle-click only
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStart.current = { x: e.clientX, y: e.clientY };
|
||||
panStart.current = currentPanRef.current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handlePanChangeRef.current({
|
||||
x: panStart.current.x + (e.clientX - dragStart.current.x),
|
||||
y: panStart.current.y + (e.clientY - dragStart.current.y),
|
||||
});
|
||||
};
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const cursor = isDragging ? 'grabbing' : currentZoom > 1 ? 'grab' : 'default';
|
||||
|
||||
return {
|
||||
currentZoom,
|
||||
effectivePan,
|
||||
containerDims,
|
||||
viewportRef,
|
||||
cursor,
|
||||
handleMouseDown,
|
||||
handleZoomChange,
|
||||
handlePanChange,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeBaseWidth(
|
||||
fitMode: 'width' | 'page' | undefined,
|
||||
explicitWidth: number | undefined,
|
||||
containerDims: { width: number; height: number },
|
||||
naturalSize: { width: number; height: number } | null,
|
||||
): number | undefined {
|
||||
if (!fitMode || containerDims.width <= 0) return explicitWidth;
|
||||
|
||||
if (fitMode === 'width') return containerDims.width;
|
||||
|
||||
if (fitMode === 'page' && naturalSize && naturalSize.height > 0 && containerDims.height > 0) {
|
||||
const aspect = naturalSize.width / naturalSize.height;
|
||||
const containerAspect = containerDims.width / containerDims.height;
|
||||
return containerAspect > aspect
|
||||
? containerDims.height * aspect
|
||||
: containerDims.width;
|
||||
}
|
||||
|
||||
return explicitWidth;
|
||||
}
|
||||
Reference in New Issue
Block a user