2026-05-20 15:39:50 +05:30
|
|
|
import React from 'react';
|
|
|
|
|
import type { Rect } from '../lib/coordinateMapping';
|
|
|
|
|
|
|
|
|
|
export interface Annotation {
|
|
|
|
|
id: string;
|
2026-06-11 19:30:27 +05:30
|
|
|
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;
|
2026-06-10 18:38:38 +05:30
|
|
|
|
|
|
|
|
// 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;
|
2026-06-10 19:29:11 +05:30
|
|
|
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,
|
2026-06-10 19:29:11 +05:30
|
|
|
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 });
|
|
|
|
|
|
2026-06-17 11:39:01 +05:30
|
|
|
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();
|
2026-06-17 11:39:01 +05:30
|
|
|
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) => {
|
2026-06-17 11:39:01 +05:30
|
|
|
// 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-17 11:39:01 +05:30
|
|
|
|
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)
|
2026-06-11 19:30:27 +05:30
|
|
|
.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}
|
2026-06-17 11:39:01 +05:30
|
|
|
onPointerDown={(e) => handlePointerDown(e, anno)}
|
2026-06-11 11:46:15 +05:30
|
|
|
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
|
|
|
|
onPointerUp={(e) => handlePointerUp(e, anno)}
|
2026-06-17 11:39:01 +05:30
|
|
|
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>
|
|
|
|
|
)}
|
2026-06-11 19:30:27 +05:30
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-10 18:38:38 +05:30
|
|
|
{anno.type === 'widget' && (
|
|
|
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
|
|
|
{anno.fieldType === 'Tx' && (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
defaultValue={anno.fieldValue || ''}
|
2026-06-10 19:29:11 +05:30
|
|
|
onBlur={(e) => {
|
|
|
|
|
if (e.target.value !== (anno.fieldValue || '')) {
|
|
|
|
|
onFieldChange?.(anno.id, e.target.value, pageIndex);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-06-10 18:38:38 +05:30
|
|
|
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'}
|
2026-06-10 19:29:11 +05:30
|
|
|
onChange={(e) => onFieldChange?.(anno.id, e.target.checked, pageIndex)}
|
2026-06-10 18:38:38 +05:30
|
|
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{anno.fieldType === 'Ch' && (
|
|
|
|
|
<select
|
|
|
|
|
defaultValue={anno.fieldValue || ''}
|
2026-06-10 19:29:11 +05:30
|
|
|
onChange={(e) => onFieldChange?.(anno.id, e.target.value, pageIndex)}
|
2026-06-10 18:38:38 +05:30
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|