Files
md-files/.claude/skills/integrate-file-viewer/code/DocumentAnnotationOverlay.tsx
T
2026-05-31 20:25:41 +00:00

230 lines
6.4 KiB
TypeScript

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;
}