Files
pdf/frontend/src/viewer/AnnotationLayer.tsx
T

228 lines
11 KiB
TypeScript
Raw Normal View History

2026-05-20 15:39:50 +05:30
import React from 'react';
import type { Rect } from '../lib/coordinateMapping';
export interface Annotation {
id: string;
type: 'highlight' | 'signature' | 'strikeout' | 'underline' | 'squiggly' | 'comment' | 'ink' | 'widget';
2026-05-20 15:39:50 +05:30
bbox: Rect;
color?: string;
2026-06-10 11:41:58 +05:30
opacity?: number;
thickness?: number;
2026-05-20 15:39:50 +05:30
author: string;
content?: string;
2026-06-09 10:35:12 +05:30
timestamp?: string;
2026-06-08 11:22:15 +05:30
paths?: { x: number; y: number }[][];
2026-06-09 10:35:12 +05:30
pageIndex?: number;
// Form fields
fieldName?: string;
fieldValue?: string;
fieldType?: string;
fieldFlags?: number;
fieldOptions?: string[];
2026-05-20 15:39:50 +05:30
}
interface AnnotationLayerProps {
pageIndex: number;
width: number;
height: number;
zoom: number;
annotations: Annotation[];
onAnnotationClick?: (annotation: Annotation) => void;
2026-06-11 11:46:15 +05:30
onAnnotationUpdate?: (annotation: Annotation) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
2026-05-20 15:39:50 +05:30
}
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
2026-06-09 10:35:12 +05:30
pageIndex,
2026-05-20 15:39:50 +05:30
width,
height,
zoom,
annotations,
onAnnotationClick,
2026-06-11 11:46:15 +05:30
onAnnotationUpdate,
onFieldChange,
2026-05-20 15:39:50 +05:30
}) => {
2026-06-11 11:46:15 +05:30
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
const isDraggable = (type: Annotation['type']) => type === 'comment' || type === 'signature';
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
2026-06-11 11:46:15 +05:30
e.stopPropagation();
if (!isDraggable(anno.type)) return; // anchored types: click-to-select only (handled on pointer-up)
setDraggingAnno(anno.id);
2026-06-11 11:46:15 +05:30
setDragStartPos({ x: e.clientX, y: e.clientY });
setDragOffset({ x: 0, y: 0 });
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent, id: string) => {
if (draggingAnno !== id) return;
setDragOffset({
x: (e.clientX - dragStartPos.x) / zoom,
y: (e.clientY - dragStartPos.y) / zoom,
});
};
const handlePointerUp = (e: React.PointerEvent, anno: Annotation) => {
// Anchored types never start a drag — a tap just selects them (recolor/delete via the panel).
if (!isDraggable(anno.type)) { onAnnotationClick?.(anno); return; }
2026-06-11 11:46:15 +05:30
if (draggingAnno !== anno.id) return;
setDraggingAnno(null);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
2026-06-11 11:46:15 +05:30
if (dragOffset.x !== 0 || dragOffset.y !== 0) {
onAnnotationUpdate?.({
...anno,
bbox: {
...anno.bbox,
x: anno.bbox.x + dragOffset.x,
y: anno.bbox.y + dragOffset.y,
}
});
} else {
onAnnotationClick?.(anno);
}
};
2026-05-20 15:39:50 +05:30
return (
<div
2026-06-10 15:16:20 +05:30
className="absolute top-0 left-0 z-30"
2026-06-08 16:27:53 +05:30
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: 'none' }}
2026-05-20 15:39:50 +05:30
>
{annotations
2026-06-09 10:35:12 +05:30
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget'].includes(anno.type))
2026-05-20 15:39:50 +05:30
.map((anno) => {
const scaledBbox = {
x: anno.bbox.x * zoom,
y: anno.bbox.y * zoom,
width: anno.bbox.width * zoom,
height: anno.bbox.height * zoom,
};
2026-06-09 10:35:12 +05:30
const formattedDate = anno.timestamp ? new Date(anno.timestamp).toLocaleString() : '';
const tooltipText = anno.type === 'comment'
? `${anno.author}${formattedDate ? ` (${formattedDate})` : ''}\n${anno.content || ''}`
: `${anno.author}: ${anno.content || ''}`;
2026-06-11 11:46:15 +05:30
const isDragging = draggingAnno === anno.id;
const currentOffset = isDragging ? dragOffset : { x: 0, y: 0 };
2026-05-20 15:39:50 +05:30
return (
<div
key={anno.id}
onPointerDown={(e) => handlePointerDown(e, anno)}
2026-06-11 11:46:15 +05:30
onPointerMove={(e) => handlePointerMove(e, anno.id)}
onPointerUp={(e) => handlePointerUp(e, anno)}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
2026-05-20 15:39:50 +05:30
style={{
2026-06-11 11:46:15 +05:30
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
2026-05-20 15:39:50 +05:30
width: `${scaledBbox.width}px`,
height: `${scaledBbox.height}px`,
2026-06-08 16:27:53 +05:30
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
2026-06-10 11:41:58 +05:30
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined,
2026-06-08 16:27:53 +05:30
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined,
pointerEvents: 'auto',
2026-05-20 15:39:50 +05:30
}}
2026-06-09 10:35:12 +05:30
title={tooltipText}
>
{anno.type === 'comment' && (
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md">
<path fillRule="evenodd" d="M4.804 21.644A6.707 6.707 0 006 21.75a6.721 6.721 0 003.583-1.029c.774.182 1.584.279 2.417.279 5.322 0 9.75-3.97 9.75-9 0-5.03-4.428-9-9.75-9s-9.75 3.97-9.75 9c0 2.409 1.025 4.587 2.674 6.192.232.226.277.428.254.543a3.73 3.73 0 01-.814 1.686.75.75 0 00.44 1.223zM8.25 10.875a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25zM10.875 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875-1.125a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25z" clipRule="evenodd" />
</svg>
</div>
)}
{anno.type === 'strikeout' && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
{anno.type === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
{anno.type === 'squiggly' && (
<svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}>
<defs>
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
<path d="M 0 2 Q 1.5 0 3 2 T 6 2" fill="none" stroke={anno.color || '#16a34a'} strokeWidth="1.2" />
</pattern>
</defs>
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
</svg>
)}
2026-06-09 10:35:12 +05:30
{anno.type === 'signature' && (
2026-06-10 15:16:20 +05:30
<div className="text-[rgba(37,99,235,0.85)] bg-[rgba(255,255,255,0.85)] rounded-full p-1 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)]">
2026-06-09 10:35:12 +05:30
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
</svg>
</div>
)}
{anno.type === 'widget' && (
<div className="w-full h-full flex items-center justify-center">
{anno.fieldType === 'Tx' && (
<input
type="text"
defaultValue={anno.fieldValue || ''}
onBlur={(e) => {
if (e.target.value !== (anno.fieldValue || '')) {
onFieldChange?.(anno.id, e.target.value, pageIndex);
}
}}
className="w-full h-full bg-blue-50/50 border border-blue-400/50 text-sm px-1 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-sm"
/>
)}
{anno.fieldType === 'Btn' && (
// Very simple checkbox or radio visualization
<input
type={(anno.fieldFlags ?? 0) & 32768 ? 'radio' : 'checkbox'}
defaultChecked={anno.fieldValue === 'Yes' || anno.fieldValue === 'On'}
onChange={(e) => onFieldChange?.(anno.id, e.target.checked, pageIndex)}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
)}
{anno.fieldType === 'Ch' && (
<select
defaultValue={anno.fieldValue || ''}
onChange={(e) => onFieldChange?.(anno.id, e.target.value, pageIndex)}
className="w-full h-full bg-blue-50/50 border border-blue-400/50 text-sm px-1 rounded-sm appearance-none"
>
{anno.fieldOptions?.map((opt: string, i: number) => (
<option key={i} value={opt}>{opt}</option>
))}
</select>
)}
{anno.fieldType === 'Sig' && (
<div className="w-full h-full border border-dashed border-gray-400 bg-gray-50/50 flex items-center justify-center text-xs text-gray-500">
Signature Field
</div>
)}
{(!anno.fieldType || anno.fieldType === 'Unknown') && (
<div className="w-full h-full border border-blue-400/50 bg-blue-50/50 flex items-center justify-center text-xs text-blue-500 opacity-50">
Form Field
</div>
)}
</div>
)}
2026-06-09 10:35:12 +05:30
</div>
2026-05-20 15:39:50 +05:30
);
})}
2026-06-09 10:35:12 +05:30
2026-06-08 11:22:15 +05:30
{/* Ink Annotations */}
2026-06-09 10:35:12 +05:30
<svg
2026-06-08 11:22:15 +05:30
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}
>
{annotations
2026-06-09 10:35:12 +05:30
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
2026-06-08 11:22:15 +05:30
.filter((anno) => anno.type === 'ink' && anno.paths)
.map((anno) => (
2026-06-10 11:41:58 +05:30
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={(anno.thickness ?? 2) * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
2026-06-08 11:22:15 +05:30
{anno.paths!.map((path, i) => {
if (path.length === 0) return null;
const d = path.map((pt, j) => `${j === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ');
return <path key={i} d={d} />;
})}
</g>
))}
</svg>
2026-05-20 15:39:50 +05:30
</div>
);
};