
Integrate File Viewer
- 1.9k installs
- 5 repo stars
- Updated August 4, 2026
- cognitedata/builder-skills
integrate-file-viewer is an agent skill that MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT m.
About
Add CogniteFileViewer to this Flows app to preview CDF files PDF image text The file viewer library files copied in Step 2 require this npm package Package Version react pdf 9 1 1 pdfjs dist ships as a dependency of react pdf at the correct version do not install it separately react and cognite sdk are assumed to already be present in Flows apps Complete these steps in order Read each file before modifying it The integrate file viewer agent skill provides documented workflows prerequisites triggers and safety guidance from its SKILL md source Agents load it when user requests match the description and follow step by step instructions without inventing capabilities It integrates with standard agent tooling for the tasks inputs outputs and failure modes described in the repository documentation
- description: "MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, t
- allowed-tools: Read, Glob, Grep, Edit, Write, Bash
- Add `CogniteFileViewer` to this Flows app to preview CDF files (PDF, image, text).
- Follow integrate-file-viewer SKILL.md steps and documented constraints.
- Follow integrate-file-viewer SKILL.md steps and documented constraints.
Integrate File Viewer by the numbers
- 1,879 all-time installs (skills.sh)
- +2 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #680 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
integrate-file-viewer capabilities & compatibility
- Capabilities
- description: "must be used whenever integrating · allowed tools: read, glob, grep, edit, write, ba · add `cognitefileviewer` to this flows app to pre · follow integrate file viewer skill.md steps and
- Use cases
- orchestration
What integrate-file-viewer says it does
description: "MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT manually wire up react-pdf or file resolution — this skill handles
allowed-tools: Read, Glob, Grep, Edit, Write, Bash
Add `CogniteFileViewer` to this Flows app to preview CDF files (PDF, image, text).
npx skills add https://github.com/cognitedata/builder-skills --skill integrate-file-viewerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.9k |
|---|---|
| repo stars | ★ 5 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | cognitedata/builder-skills ↗ |
When should an agent use integrate-file-viewer and what problem does it solve?
MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT manually wire up react-pdf or file resolution — this skill handles installation,
Who is it for?
Developers invoking integrate-file-viewer as documented in the skill source.
Skip if: Skip when requirements fall outside integrate-file-viewer documented scope.
When should I use this skill?
MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT manually wire up react-pdf or file resolution — this skill handles installation,
What you get
Outputs aligned with the integrate-file-viewer SKILL.md workflow and stated deliverables.
- CogniteFileViewer React components
- Annotation overlay integration
Files
Integrate CogniteFileViewer
Add CogniteFileViewer to this Flows app to preview CDF files (PDF, image, text).
Dependencies
The file-viewer library files (copied in Step 2) require this npm package:
| Package | Version |
|---|---|
react-pdf | ^9.1.1 |
pdfjs-dist ships as a dependency of react-pdf at the correct version — do not install it separately. react and @cognite/sdk are assumed to already be present in Flows apps.
---
Your job
Complete these steps in order. Read each file before modifying it.
---
Step 1 — Understand the app
Read these files before touching anything:
package.json— detect package manager (packageManagerfield or lock file) and existing depsvite.config.ts— understand current Vite setup- The component where the viewer should be added
---
Step 2 — Copy the file-viewer source files
The file-viewer library lives in the code/ directory next to this skill file. Read and copy all files from there into src/cognite-file-viewer/ inside the app:
code/types.tscode/mimeTypes.tscode/fileResolution.tscode/useViewport.tscode/useFileResolver.tscode/useDocumentAnnotations.tscode/DocumentAnnotationOverlay.tsxcode/CogniteFileViewer.tsxcode/index.ts
The PDF.js worker is configured inside CogniteFileViewer.tsx — no separate consumer setup is needed.---
Step 3 — Install dependencies
Install react-pdf (see Dependencies above) using the app's package manager:
- pnpm →
pnpm add react-pdf@^9.1.1 - npm →
npm install react-pdf@^9.1.1 - yarn →
yarn add react-pdf@^9.1.1
pnpm users: pnpm's strict linking may prevent the browser from resolvingpdfjs-dist. Either addpdfjs-distas a direct dependency (pnpm add pdfjs-dist), or addpublic-hoist-pattern[]=pdfjs-distto.npmrc.
---
Step 4 — Configure Vite
Add optimizeDeps.exclude: ['pdfjs-dist'] to vite.config.ts to prevent Vite from pre-bundling pdfjs-dist (which breaks the worker):
export default defineConfig({
// ... existing config ...
optimizeDeps: {
exclude: ['pdfjs-dist'],
},
});---
Step 5 — Use the component
Import and render CogniteFileViewer from the locally copied files:
import { CogniteFileViewer } from './cognite-file-viewer';Get the sdk from the useDune() hook (already available in every Flows app):
import { useDune } from '@cognite/dune';
const { sdk } = useDune();Supported file types
| Type | Formats |
|---|---|
.pdf — page navigation, zoom, pan, diagram annotation overlay | |
| Office documents | Word, PowerPoint, Excel, ODS, ODP, ODT, RTF, TSV — converted to PDF via the CDF Document Preview API, then rendered identically to PDF |
| Image | JPEG, PNG, WebP, SVG, TIFF — zoom, pan, rotation |
| Text | .txt, .csv, .json — rendered as preformatted text |
| Other | Falls back to renderUnsupported |
Minimal usage
This is all you need — zoom, pan, and touch gestures are handled internally:
<CogniteFileViewer
source={{ type: 'internalId', id: file.id }}
client={sdk}
style={{ width: '100%', height: '600px' }}
/>The component needs a defined height. If the parent has no explicit height, the viewer will collapse to zero. Always set aheightviastyle,className, or the parent container.
File source
Pass any of three source types:
// By instance ID (data-modelled file — enables annotations)
<CogniteFileViewer
source={{ type: 'instanceId', space: 'my-space', externalId: 'my-file' }}
client={sdk}
/>
// By CDF internal ID
<CogniteFileViewer
source={{ type: 'internalId', id: 12345 }}
client={sdk}
/>
// By direct URL
<CogniteFileViewer
source={{ type: 'url', url: 'https://...', mimeType: 'application/pdf' }}
/>Prefer `instanceId` when available — it's the only source type that enables the diagram annotation overlay. When listing files via sdk.files.list(), check file.instanceId first:
source={
file.instanceId
? { type: 'instanceId', space: file.instanceId.space, externalId: file.instanceId.externalId }
: { type: 'internalId', id: file.id }
}Full props reference
<CogniteFileViewer
// Required
source={source}
client={sdk} // required for instanceId and internalId sources
// PDF pagination
page={page} // controlled current page (1-indexed)
onPageChange={setPage}
onDocumentLoad={({ numPages }) => setNumPages(numPages)}
// Zoom & pan (works on PDF and images)
zoom={zoom} // 1 = 100%; Ctrl/Cmd+wheel, pinch-to-zoom, and middle-click drag built in
onZoomChange={setZoom}
minZoom={0.25} // default
maxZoom={5} // default
panOffset={pan} // controlled pan offset; resets on page change
onPanChange={setPan}
// Fit mode
fitMode="width" // 'width' fits to container width; 'page' fits entire page in container
// Rotation (PDFs and images)
rotation={rotation} // 0 | 90 | 180 | 270
// Diagram annotations (instanceId sources only)
showAnnotations={true} // default
onAnnotationClick={(annotation) => { /* annotation.linkedResource has space + externalId */ }}
onAnnotationHover={(annotation) => {}}
// Custom annotation tooltip (replaces native <title> tooltip)
renderAnnotationTooltip={(annotation, rect) => (
<div style={{
position: 'absolute',
left: rect.x + rect.width,
top: rect.y,
zIndex: 11,
}}>
{annotation.text}
</div>
)}
// Custom overlay (SVG paths, highlights, drawings — works on PDF and images)
renderOverlay={({ width, height, originalWidth, originalHeight, pageNumber, rotation }) => (
<svg
width={width}
height={height}
viewBox={`0 0 ${originalWidth} ${originalHeight}`}
preserveAspectRatio="none"
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'all' }}
>
<path d="..." stroke="cyan" fill="none" />
</svg>
)}
// Custom renderers (all optional)
renderLoading={() => <MySpinner />}
renderError={(error) => <MyError message={error.message} />}
renderUnsupported={(mimeType) => <div>Cannot preview {mimeType}</div>}
// Layout
className="..."
style={{ width: '100%', height: '100%' }}
/>---
Tips & tricks
Reset page, zoom and rotation when the source changes. The component does not reset these automatically when you switch files — do it yourself:
const navigateToFile = (file: FileInfo) => {
setSelectedFile(file);
setPage(1);
setZoom(1);
setRotation(0);
};Gate pagination UI on `numPages > 0`. onDocumentLoad only fires for PDFs. Don't render pagination controls until you know there are pages to paginate:
{numPages > 0 && (
<>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>‹</button>
<span>{page} / {numPages}</span>
<button disabled={page >= numPages} onClick={() => setPage(p => p + 1)}>›</button>
</>
)}Annotation click → navigate to linked file. annotation.linkedResource contains the space and externalId of the linked CDF instance. Match it against file.instanceId to navigate:
onAnnotationClick={(annotation) => {
if (!annotation.linkedResource) return;
const { space, externalId } = annotation.linkedResource;
const linked = files.find(
f => f.instanceId?.space === space && f.instanceId?.externalId === externalId
);
if (linked) navigateToFile(linked);
}}Touch support is built in. Two-finger pinch-to-zoom and two-finger drag-to-pan work on touch devices automatically. No configuration needed.
Pan is middle-click drag (when zoomed in) on desktop. Left-click remains free for annotation clicks and text selection.
Ctrl/Cmd + wheel zooms toward the cursor — also built in. Wire zoom/onZoomChange if you want programmatic zoom buttons or to persist zoom state; otherwise it works fully uncontrolled.
`renderOverlay` receives original page dimensions (originalWidth, originalHeight) so you can set up an SVG viewBox in the original coordinate space. Paths drawn in PDF-point or image-pixel coordinates will map correctly to the rendered page at any zoom level.
---
Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
Failed to resolve module specifier 'pdf.worker.mjs' | pdfjs-dist not hoisted (pnpm) | Add public-hoist-pattern[]=pdfjs-dist to .npmrc, or pnpm add pdfjs-dist directly |
API version does not match Worker version | pdfjs-dist version mismatch between app and react-pdf | Do not install pdfjs-dist separately — let react-pdf provide it. If already installed, remove it |
| Annotations never show | instanceId is undefined — annotation overlay is disabled without it | Use instanceId source, or fall back and accept no annotations for classic files |
| Annotations show but are empty | File has no CogniteDiagramAnnotation edges in CDF | Expected — only P&ID/diagram files synced to the data model have annotations |
| Viewer collapses to zero height | Parent has no explicit height | Set height via style, className, or parent CSS |
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>
);
};
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;
}
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;
}
// 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';
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';
}
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;
}
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 };
}
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;
}
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;
}
Related skills
How it compares
Pick integrate-file-viewer over generic PDF snippets when SaaS apps need mime routing plus annotation overlays wired into React state.
FAQ
What is integrate-file-viewer?
MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT manually wire up react-pdf or file resolution — this skill ha
When should I use integrate-file-viewer?
MUST be used whenever integrating CogniteFileViewer into a Flows app to preview CDF files (PDFs, images, text). Do NOT manually wire up react-pdf or file resolution — this skill ha
Is integrate-file-viewer safe to install?
Review the Security Audits panel on this page before production use.