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

220 lines
10 KiB
TypeScript

import { CustomButton } from '../components/custom/CustomButton';
import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
import type { Rect } from '../lib/coordinateMapping';
import type { StampPreset } from '../lib/tools';
interface OverlayLayerProps {
pageIndex: number;
width: number;
height: number;
activeTool: string;
zoom: number;
inkColor: string;
inkThickness: number;
textColor: string;
fontSize: number;
hasSignature: boolean;
activeStamp: StampPreset | null;
onAnnotationAdded?: (anno: Annotation) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
/** Called when user clicks to begin interactive signature placement */
onBeginPlacement?: (pageIndex: number, viewportPt: { x: number; y: number }) => void;
}
const TEXTBOX_WIDTH_PTS = 200;
const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
pageIndex, width, height, activeTool, zoom,
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
}) => {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
const [commentPopup, setCommentPopup] = useState<{ x: number; y: number } | null>(null);
const [commentText, setCommentText] = useState('');
const [textBox, setTextBox] = useState<{ x: number; y: number } | null>(null);
const [textValue, setTextValue] = useState('');
const svgRef = useRef<SVGSVGElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const getCoordinates = (e: React.MouseEvent | React.PointerEvent) => {
const el = svgRef.current ?? rootRef.current;
if (!el) return { x: 0, y: 0 };
const rect = el.getBoundingClientRect();
return { x: (e.clientX - rect.left) / zoom, y: (e.clientY - rect.top) / zoom };
};
const handlePointerDown = (e: React.PointerEvent) => {
if (activeTool !== 'draw') return;
setIsDrawing(true);
if (e.target instanceof Element) e.target.setPointerCapture(e.pointerId);
setCurrentPath([getCoordinates(e)]);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setCurrentPath((prev) => [...prev, getCoordinates(e)]);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setIsDrawing(false);
if (e.target instanceof Element) e.target.releasePointerCapture(e.pointerId);
if (currentPath.length > 1) {
const xs = currentPath.map((p) => p.x);
const ys = currentPath.map((p) => p.y);
onAnnotationAdded?.({
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'ink',
pageIndex,
bbox: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) },
author: 'Current User',
paths: [currentPath],
color: inkColor,
thickness: inkThickness,
});
}
setCurrentPath([]);
};
const handleLayerClick = (e: React.MouseEvent) => {
const coords = getCoordinates(e);
if (activeTool === 'comment') {
setCommentPopup(coords);
setCommentText('');
} else if (activeTool === 'textbox') {
setTextBox(coords);
setTextValue('');
} else if (activeTool === 'stamp' && activeStamp) {
onPlaceStamp?.(pageIndex, coords);
} else if (activeTool === 'signature' && hasSignature) {
// Pass viewport coordinates (relative to page) to begin interactive placement
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
onBeginPlacement?.(pageIndex, {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
const handleCommentSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (commentPopup && commentText.trim() !== '') {
onAnnotationAdded?.({
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'comment',
bbox: { x: commentPopup.x, y: commentPopup.y, width: 24, height: 24 },
author: 'Current User',
content: commentText.trim(),
pageIndex,
timestamp: new Date().toISOString(),
});
}
setCommentPopup(null);
setCommentText('');
};
const commitTextBox = () => {
if (textBox && textValue.trim() !== '') {
const heightPts = fontSize * 1.8;
onPlaceText?.(pageIndex, { x: textBox.x, y: textBox.y, width: TEXTBOX_WIDTH_PTS, height: heightPts }, textValue.trim());
}
setTextBox(null);
setTextValue('');
};
return (
<div
ref={rootRef}
className="absolute top-0 left-0 z-40"
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: POINTER_TOOLS.includes(activeTool) ? 'auto' : 'none' }}
onClick={handleLayerClick}
>
{activeTool === 'signature' && !hasSignature && (
<div className="absolute inset-0 bg-[rgba(37,99,235,0.05)] flex items-center justify-center border-2 border-dashed border-[rgba(37,99,235,0.4)] rounded-[6px]"><span className="bg-[#2563eb] text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
)}
{activeTool === 'signature' && hasSignature && !commentPopup && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
)}
{activeTool === 'comment' && !commentPopup && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
)}
{activeTool === 'stamp' && activeStamp && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp.label}</div>
)}
{activeTool === 'textbox' && !textBox && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
)}
{commentPopup && (
<div
className="w-[264px] bg-[#ffffff] border border-[#ebedf0] rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleCommentSubmit}>
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
<span className="text-xs font-bold">Add sticky note</span>
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</CustomButton>
</div>
<textarea
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here…" className="w-full bg-transparent text-[#18212e] border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-[#98a1ad]" rows={3}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
/>
<div className="py-[10px] px-[14px] bg-[#f6f7f9] border-t border-[#ebedf0] flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-[#2563eb] text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-[#1d4ed8]">Save note</CustomButton></div>
</form>
</div>
)}
{textBox && (
<textarea
autoFocus
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={commitTextBox}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
}}
className="absolute z-50 bg-[rgba(255,255,255,0.85)] border-[1.5px] border-[#2563eb] rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
placeholder="Type…"
style={{
left: `${textBox.x * zoom}px`,
top: `${textBox.y * zoom}px`,
width: `${TEXTBOX_WIDTH_PTS * zoom}px`,
minHeight: `${fontSize * 1.8 * zoom}px`,
fontSize: `${fontSize * zoom}px`,
color: textColor,
}}
/>
)}
{activeTool === 'draw' && (
<svg
ref={svgRef}
style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{currentPath.length > 0 && (
<path
d={currentPath.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ')}
stroke={inkColor} strokeWidth={inkThickness * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round"
/>
)}
</svg>
)}
</div>
);
};