This commit is contained in:
Ole
2026-05-31 20:25:41 +00:00
commit 0a07ab8593
275 changed files with 52660 additions and 0 deletions
@@ -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;
}