220 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|