385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { gatewayService, type LayoutBlock } from '../lib/gatewayService';
|
|
|
|
/** Map a raw PDF font name to a CSS font-family stack */
|
|
function fallbackFamily(fontName: string): string {
|
|
const n = (fontName || '').toLowerCase().replace(/^[a-z]{6}\+/, '');
|
|
if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) {
|
|
return '"Times New Roman", Times, Georgia, serif';
|
|
}
|
|
if (n.includes('courier') || n.includes('mono')) {
|
|
return '"Courier New", Courier, monospace';
|
|
}
|
|
if (n.includes('arial') || n.includes('helvetica') || n.includes('sans')) {
|
|
return 'Arial, "Helvetica Neue", Helvetica, sans-serif';
|
|
}
|
|
// Unknown font: try to use it directly as a web-safe name, fall back to sans-serif
|
|
const clean = fontName.replace(/^[A-Z]{6}\+/, '').trim();
|
|
return clean ? `"${clean}", Arial, sans-serif` : 'Arial, sans-serif';
|
|
}
|
|
|
|
export interface BlockEditPayload {
|
|
blockId: string;
|
|
type: 'bounds' | 'text' | 'delete';
|
|
bounds?: { x: number; y: number; width: number; height: number };
|
|
text?: string;
|
|
}
|
|
|
|
interface LayoutBlockLayerProps {
|
|
blocks: LayoutBlock[];
|
|
scale: number;
|
|
selectedBlockId: string | null;
|
|
onSelectBlock: (blockId: string | null) => void;
|
|
onUpdateBlockBounds?: (
|
|
blockId: string,
|
|
bounds: { x: number; y: number; width: number; height: number }
|
|
) => void;
|
|
/** Original PDF-baked bounds for each block, keyed by block ID.
|
|
* Survives page navigation so we can still show eraser + image tile after remount. */
|
|
originalBoundsMap?: Record<string, { x: number; y: number; width: number; height: number }>;
|
|
/** Called when a block is first dragged so the parent can persist its original bounds. */
|
|
onRecordOriginalBounds?: (
|
|
blockId: string,
|
|
bounds: { x: number; y: number; width: number; height: number }
|
|
) => void;
|
|
/** For font detection on click — required for OCR blocks */
|
|
documentId?: string;
|
|
pageIndex?: number;
|
|
}
|
|
|
|
type HandleType = 'move' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
|
|
|
const DRAG_THRESHOLD_PX = 4;
|
|
|
|
export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
|
blocks,
|
|
scale,
|
|
selectedBlockId,
|
|
onSelectBlock,
|
|
onUpdateBlockBounds,
|
|
originalBoundsMap = {},
|
|
onRecordOriginalBounds,
|
|
documentId,
|
|
pageIndex,
|
|
}) => {
|
|
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
|
// Cache of detected fonts keyed by block id: { fontName, fontSize, isBold }
|
|
const [detectedFonts, setDetectedFonts] = useState<Record<string, { fontName?: string; fontSize?: number; isBold?: boolean }>>({});
|
|
|
|
// Live bounds during drag — keys are block IDs
|
|
const [tempBounds, setTempBounds] = useState<
|
|
Record<string, { x: number; y: number; width: number; height: number }>
|
|
>({});
|
|
|
|
const latestBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
|
|
|
|
const startDrag = (
|
|
e: React.PointerEvent,
|
|
block: LayoutBlock,
|
|
handle: HandleType
|
|
) => {
|
|
e.stopPropagation();
|
|
const target = e.currentTarget as HTMLElement;
|
|
try { target.setPointerCapture(e.pointerId); } catch {}
|
|
onSelectBlock(block.id);
|
|
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
const currentBounds = tempBounds[block.id] || block.bounds;
|
|
const initial = {
|
|
x: Number(currentBounds.x) || 0,
|
|
y: Number(currentBounds.y) || 0,
|
|
width: Number(currentBounds.width) || 50,
|
|
height: Number(currentBounds.height) || 50,
|
|
};
|
|
latestBoundsRef.current = { ...initial };
|
|
let dragStarted = false;
|
|
|
|
const onPointerMove = (ev: PointerEvent) => {
|
|
ev.stopPropagation();
|
|
const rawDx = ev.clientX - startX;
|
|
const rawDy = ev.clientY - startY;
|
|
|
|
// Ignore tiny tremors — must exceed threshold to be a real drag
|
|
if (!dragStarted) {
|
|
if (Math.hypot(rawDx, rawDy) < DRAG_THRESHOLD_PX) return;
|
|
dragStarted = true;
|
|
// Record original PDF-canvas position on first real drag (once per block)
|
|
if (!originalBoundsMap[block.id] && onRecordOriginalBounds) {
|
|
onRecordOriginalBounds(block.id, { ...block.bounds });
|
|
}
|
|
}
|
|
|
|
const dx = rawDx / scale;
|
|
const dy = rawDy / scale;
|
|
const MIN = 20;
|
|
let { x, y, width, height } = initial;
|
|
|
|
if (handle === 'move') {
|
|
x = Math.max(0, initial.x + dx);
|
|
y = Math.max(0, initial.y + dy);
|
|
} else {
|
|
switch (handle) {
|
|
case 'se': width = Math.max(MIN, initial.width + dx); height = Math.max(MIN, initial.height + dy); break;
|
|
case 's': height = Math.max(MIN, initial.height + dy); break;
|
|
case 'e': width = Math.max(MIN, initial.width + dx); break;
|
|
case 'nw':
|
|
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width);
|
|
height = Math.max(MIN, initial.height - dy); y = initial.y + (initial.height - height);
|
|
break;
|
|
case 'n':
|
|
height = Math.max(MIN, initial.height - dy); y = initial.y + (initial.height - height); break;
|
|
case 'w':
|
|
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width); break;
|
|
case 'ne':
|
|
width = Math.max(MIN, initial.width + dx);
|
|
height = Math.max(MIN, initial.height - dy); y = initial.y + (initial.height - height);
|
|
break;
|
|
case 'sw':
|
|
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width);
|
|
height = Math.max(MIN, initial.height + dy);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const updated = { x, y, width, height };
|
|
latestBoundsRef.current = updated;
|
|
setTempBounds((prev) => ({ ...prev, [block.id]: updated }));
|
|
};
|
|
|
|
const onPointerUp = (ev: PointerEvent) => {
|
|
ev.stopPropagation();
|
|
try { target.releasePointerCapture(e.pointerId); } catch {}
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
window.removeEventListener('pointerup', onPointerUp);
|
|
if (dragStarted && latestBoundsRef.current && onUpdateBlockBounds) {
|
|
onUpdateBlockBounds(block.id, latestBoundsRef.current);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('pointermove', onPointerMove);
|
|
window.addEventListener('pointerup', onPointerUp);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="absolute inset-0 pointer-events-none z-[45] overflow-visible"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
onSelectBlock(null);
|
|
setEditingBlockId(null);
|
|
}
|
|
}}
|
|
>
|
|
{blocks.map((block) => {
|
|
const isSelected = selectedBlockId === block.id;
|
|
const isEditing = editingBlockId === block.id;
|
|
const isImage = block.type === 'image';
|
|
const isOcrBlock = block.id.startsWith('ocr_');
|
|
|
|
const bounds = tempBounds[block.id] || block.bounds;
|
|
const left = bounds.x * scale;
|
|
const top = bounds.y * scale;
|
|
const width = bounds.width * scale;
|
|
const height = bounds.height * scale;
|
|
|
|
// Use parent-persisted original bounds (survives page navigation)
|
|
const orig = originalBoundsMap[block.id] || block.bounds;
|
|
// Block has moved if we have recorded original bounds that differ from current
|
|
const hasMoved = !!tempBounds[block.id] || (
|
|
!!originalBoundsMap[block.id] && (
|
|
Math.abs(bounds.x - orig.x) > 0.5 ||
|
|
Math.abs(bounds.y - orig.y) > 0.5 ||
|
|
Math.abs(bounds.width - orig.width) > 0.5 ||
|
|
Math.abs(bounds.height - orig.height) > 0.5
|
|
)
|
|
);
|
|
|
|
const imageUrl = block.imageUrl
|
|
? block.imageUrl.startsWith('http')
|
|
? block.imageUrl
|
|
: `${gatewayService.baseUrl}${block.imageUrl}`
|
|
: null;
|
|
|
|
return (
|
|
<React.Fragment key={block.id}>
|
|
{/*
|
|
* WHITE ERASER — paints over the baked-in PDF canvas content at its
|
|
* ORIGINAL position. Active when moved OR when it's an OCR block covering scanned text.
|
|
* z-44 puts it below the block (z-45) but above the PDF canvas.
|
|
*/}
|
|
{/*
|
|
* WHITE ERASER — paints over the baked-in PDF canvas content at its
|
|
* ORIGINAL position. Only active once a real drag/move has occurred.
|
|
* z-44 puts it below the block (z-45) but above the PDF canvas.
|
|
*/}
|
|
{hasMoved && (
|
|
<div
|
|
className="absolute pointer-events-none"
|
|
style={{
|
|
left: `${orig.x * scale}px`,
|
|
top: `${orig.y * scale}px`,
|
|
width: `${orig.width * scale}px`,
|
|
height:`${orig.height * scale}px`,
|
|
backgroundColor: '#ffffff',
|
|
zIndex: 44,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* ── MAIN BLOCK ── */}
|
|
<div
|
|
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto group cursor-move'} select-none rounded-[2px]`}
|
|
style={{
|
|
left, top, width, height,
|
|
// Selection ring via outline so it doesn't affect layout
|
|
outline: isSelected ? '2px solid #2563eb' : undefined,
|
|
outlineOffset: '0px',
|
|
// White bg after drag to cover canvas underneath, transparent when idle to preserve original scanned text font
|
|
backgroundColor: hasMoved ? '#ffffff' : isSelected ? 'rgba(37,99,235,0.06)' : 'transparent',
|
|
zIndex: 45,
|
|
}}
|
|
onPointerDown={(e) => startDrag(e, block, 'move')}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectBlock(block.id);
|
|
// For OCR blocks: detect the real font from glyphs at this position
|
|
if (isOcrBlock && documentId !== undefined && pageIndex !== undefined
|
|
&& !detectedFonts[block.id]) {
|
|
const b = block.bounds;
|
|
gatewayService.getFontAtPosition(documentId, pageIndex, b.x, b.y, b.width, b.height)
|
|
.then((info) => {
|
|
if (info.fontName) {
|
|
setDetectedFonts((prev) => ({ ...prev, [block.id]: info }));
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectBlock(block.id);
|
|
setEditingBlockId(block.id);
|
|
}}
|
|
>
|
|
{/* Label badge — shows block type; for OCR selected blocks shows detected font */}
|
|
<div className="absolute -top-5 left-0 opacity-0 group-hover:opacity-100 transition-opacity bg-[#1e293b] text-white text-[10px] px-1.5 py-0.5 rounded font-mono shadow pointer-events-none" style={{ zIndex: 60 }}>
|
|
{isOcrBlock && isSelected && detectedFonts[block.id]?.fontName
|
|
? `${detectedFonts[block.id].fontName} ${detectedFonts[block.id].fontSize?.toFixed(0)}pt`
|
|
: block.type.toUpperCase()}
|
|
</div>
|
|
|
|
{/* ── IMAGE CONTENT ── */}
|
|
{isImage && (
|
|
<>
|
|
{/* When dragged: show the rendered image tile */}
|
|
{hasMoved && imageUrl && (
|
|
<img
|
|
key={imageUrl}
|
|
src={imageUrl}
|
|
alt="PDF Image"
|
|
draggable={false}
|
|
className="absolute inset-0 w-full h-full select-none pointer-events-none"
|
|
style={{ objectFit: 'fill', display: 'block' }}
|
|
/>
|
|
)}
|
|
{/* When dragged but no imageUrl: show a visible placeholder */}
|
|
{hasMoved && !imageUrl && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white pointer-events-none">
|
|
<svg className="w-8 h-8 text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── NON-IMAGE TEXT CONTENT PREVIEW ── */}
|
|
{!isImage && !isEditing && (
|
|
<div
|
|
className="w-full h-full pointer-events-none flex flex-col justify-center leading-none font-sans"
|
|
style={{
|
|
fontSize: `${block.textStyle?.fontSize || Math.max(10, Math.min(24, height * 0.8))}px`,
|
|
// For OCR text blocks that haven't been moved, keep text transparent so the original scanned image text with its original font remains 100% visible
|
|
color: (isOcrBlock && !hasMoved) ? 'transparent' : (block.textStyle?.fontColor || 'black'),
|
|
fontWeight: (block.textStyle?.isBold || (block.textStyle?.fontName && /bold|black|heavy/i.test(block.textStyle.fontName))) ? 'bold' : 'normal',
|
|
fontFamily: block.textStyle?.fontName
|
|
? fallbackFamily(block.textStyle.fontName)
|
|
: undefined
|
|
}}
|
|
>
|
|
{block.children && block.children.length > 0 ? (
|
|
block.children.map((l: any, idx: number) => (
|
|
<div key={idx} className="whitespace-nowrap">
|
|
{typeof l === 'string' ? l : (l.text ?? '')}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="whitespace-nowrap">{block.text ?? ''}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── SELECTION HANDLES ── */}
|
|
{isSelected && (
|
|
<>
|
|
{/* corners */}
|
|
{(['nw','ne','sw','se'] as HandleType[]).map((h) => (
|
|
<div key={h}
|
|
className={`absolute w-3.5 h-3.5 bg-white border-2 border-[#2563eb] rounded-full shadow pointer-events-auto ${
|
|
h==='nw' ? '-top-1.5 -left-1.5 cursor-nwse-resize' :
|
|
h==='ne' ? '-top-1.5 -right-1.5 cursor-nesw-resize' :
|
|
h==='sw' ? '-bottom-1.5 -left-1.5 cursor-nesw-resize' :
|
|
'-bottom-1.5 -right-1.5 cursor-nwse-resize'
|
|
}`}
|
|
style={{ zIndex: 60 }}
|
|
onPointerDown={(e) => startDrag(e, block, h)}
|
|
/>
|
|
))}
|
|
{/* edges */}
|
|
{(['n','s','w','e'] as HandleType[]).map((h) => (
|
|
<div key={h}
|
|
className={`absolute w-3.5 h-3.5 bg-white border-2 border-[#2563eb] rounded-full shadow pointer-events-auto ${
|
|
h==='n' ? '-top-1.5 left-1/2 -translate-x-1/2 cursor-ns-resize' :
|
|
h==='s' ? '-bottom-1.5 left-1/2 -translate-x-1/2 cursor-ns-resize' :
|
|
h==='w' ? 'top-1/2 -left-1.5 -translate-y-1/2 cursor-ew-resize' :
|
|
'top-1/2 -right-1.5 -translate-y-1/2 cursor-ew-resize'
|
|
}`}
|
|
style={{ zIndex: 60 }}
|
|
onPointerDown={(e) => startDrag(e, block, h)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* ── TEXT EDITOR (non-image) ── */}
|
|
{isEditing && !isImage && (
|
|
<textarea
|
|
className="w-full h-full bg-white text-black border-none outline-none shadow-lg resize-none pointer-events-auto flex flex-col justify-center leading-none font-sans"
|
|
style={{
|
|
fontSize: `${block.textStyle?.fontSize || Math.max(10, Math.min(24, height * 0.8))}px`,
|
|
color: block.textStyle?.fontColor || 'black',
|
|
fontWeight: (block.textStyle?.isBold || (block.textStyle?.fontName && /bold|black|heavy/i.test(block.textStyle.fontName))) ? 'bold' : 'normal',
|
|
fontFamily: block.textStyle?.fontName
|
|
? fallbackFamily(block.textStyle.fontName)
|
|
: undefined
|
|
}}
|
|
autoFocus
|
|
defaultValue={
|
|
block.children && block.children.length > 0
|
|
? block.children.map((l: any) => (typeof l === 'string' ? l : (l.text ?? ''))).join('\n')
|
|
: (block.text ?? '')
|
|
}
|
|
onBlur={() => setEditingBlockId(null)}
|
|
onKeyDown={(e) => { if (e.key === 'Escape') setEditingBlockId(null); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|