Files
pdf/frontend/src/viewer/LayoutBlockLayer.tsx
T
2026-08-04 18:30:40 +05:30

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>
);
};