forked from hangshuo652/aurak
0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import * as pdfjs from 'pdfjs-dist';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
|
|
// Set worker path for PDF.js
|
|
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
|
|
|
|
// Helper function to convert coordinates between different scales
|
|
const convertCoordinates = (
|
|
x: number,
|
|
y: number,
|
|
containerWidth: number,
|
|
containerHeight: number,
|
|
pdfWidth: number,
|
|
pdfHeight: number,
|
|
zoomLevel: number = 1.0
|
|
) => {
|
|
// Calculate the scale factors to fit the PDF page in the container while maintaining aspect ratio
|
|
const scaleX = containerWidth / pdfWidth;
|
|
const scaleY = containerHeight / pdfHeight;
|
|
let scale = Math.min(scaleX, scaleY);
|
|
|
|
// Apply zoom level to the scale
|
|
scale *= zoomLevel;
|
|
|
|
// Calculate padding offsets to center the PDF page in the container
|
|
const paddedWidth = pdfWidth * scale;
|
|
const paddedHeight = pdfHeight * scale;
|
|
const offsetX = (containerWidth - paddedWidth) / 2;
|
|
const offsetY = (containerHeight - paddedHeight) / 2;
|
|
|
|
// Convert from container coordinates to PDF page coordinates
|
|
const pdfX = (x - offsetX) / scale;
|
|
const pdfY = (y - offsetY) / scale;
|
|
|
|
return { x: pdfX, y: pdfY, scale, offsetX, offsetY };
|
|
};
|
|
|
|
// Function to calculate how PDF page is laid out in container space
|
|
const calculatePDFLayout = (
|
|
containerWidth: number,
|
|
containerHeight: number,
|
|
pdfWidth: number,
|
|
pdfHeight: number,
|
|
zoomLevel: number = 1.0
|
|
) => {
|
|
// Calculate the scale factors to fit the PDF page in the container while maintaining aspect ratio
|
|
const scaleX = containerWidth / pdfWidth;
|
|
const scaleY = containerHeight / pdfHeight;
|
|
let pageScale = Math.min(scaleX, scaleY);
|
|
|
|
// Apply zoom level to the page scale
|
|
pageScale *= zoomLevel;
|
|
|
|
// Calculate padding offsets to center the PDF page in the container
|
|
const paddedWidth = pdfWidth * pageScale;
|
|
const paddedHeight = pdfHeight * pageScale;
|
|
const offsetX = (containerWidth - paddedWidth) / 2;
|
|
const offsetY = (containerHeight - paddedHeight) / 2;
|
|
|
|
return {
|
|
pageScale,
|
|
offsetX: Math.round(offsetX),
|
|
offsetY: Math.round(offsetY),
|
|
paddedWidth,
|
|
paddedHeight
|
|
};
|
|
};
|
|
|
|
// Enhanced function to calculate precise PDF page layout with improved accuracy
|
|
const calculatePrecisePDFLayout = (
|
|
containerWidth: number,
|
|
containerHeight: number,
|
|
pdfWidth: number,
|
|
pdfHeight: number,
|
|
zoomLevel: number = 1.0
|
|
) => {
|
|
// Calculate scale to fit the PDF page in the container while maintaining aspect ratio
|
|
const scaleX = containerWidth / pdfWidth;
|
|
const scaleY = containerHeight / pdfHeight;
|
|
let pageScale = Math.min(scaleX, scaleY);
|
|
|
|
// Apply zoom level to the page scale
|
|
pageScale *= zoomLevel;
|
|
|
|
// Calculate exact page dimensions after scaling
|
|
const scaledPageWidth = pdfWidth * pageScale;
|
|
const scaledPageHeight = pdfHeight * pageScale;
|
|
|
|
// Calculate padding to center the page in the container
|
|
const offsetX = (containerWidth - scaledPageWidth) / 2;
|
|
const offsetY = (containerHeight - scaledPageHeight) / 2;
|
|
|
|
return {
|
|
pageScale,
|
|
offsetX: offsetX,
|
|
offsetY: offsetY,
|
|
scaledPageWidth,
|
|
scaledPageHeight,
|
|
containerWidth,
|
|
containerHeight
|
|
};
|
|
};
|
|
|
|
export interface SelectionCoordinates {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface PDFSelectionToolProps {
|
|
containerRef: React.RefObject<HTMLDivElement>;
|
|
canvasRef: React.RefObject<HTMLCanvasElement>;
|
|
onSelectionComplete: (screenshot: Blob, text: string) => void;
|
|
onCancel: () => void;
|
|
pdfBlob: Blob | null;
|
|
pageNumber: number;
|
|
authToken: string;
|
|
zoomLevel?: number; // Optional zoom level parameter
|
|
}
|
|
|
|
export const PDFSelectionTool: React.FC<PDFSelectionToolProps> = ({
|
|
containerRef,
|
|
canvasRef,
|
|
onSelectionComplete,
|
|
onCancel,
|
|
pdfBlob,
|
|
pageNumber,
|
|
authToken,
|
|
zoomLevel = 1.0, // Default zoom level is 1.0
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const [isSelecting, setIsSelecting] = useState(false);
|
|
const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
|
|
const [currentPoint, setCurrentPoint] = useState<{ x: number; y: number } | null>(null);
|
|
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onCancel();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onCancel]);
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (!containerRef.current || !pdfBlob) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
// Use actual coordinates relative to container
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
setStartPoint({ x, y });
|
|
setCurrentPoint({ x, y });
|
|
setIsSelecting(true);
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!isSelecting || !containerRef.current) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
setCurrentPoint({ x, y });
|
|
};
|
|
|
|
const handleMouseUp = async () => {
|
|
if (!isSelecting || !startPoint || !currentPoint || !containerRef.current || !canvasRef.current) return;
|
|
|
|
setIsSelecting(false);
|
|
|
|
// Calculate selection rectangle based on mouse events (in container coordinates)
|
|
const startX = Math.min(startPoint.x, currentPoint.x);
|
|
const startY = Math.min(startPoint.y, currentPoint.y);
|
|
const endX = Math.max(startPoint.x, currentPoint.x);
|
|
const endY = Math.max(startPoint.y, currentPoint.y);
|
|
const width = endX - startX;
|
|
const height = endY - startY;
|
|
|
|
// Minimum selection size
|
|
if (width < 10 || height < 10) {
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get the actual canvas element from PDFPreview
|
|
const sourceCanvas = canvasRef.current;
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
|
|
console.log('=== Direct Canvas Capture ===');
|
|
console.log('Container dimensions:', { width: containerRect.width, height: containerRect.height });
|
|
console.log('Canvas dimensions:', { width: sourceCanvas.width, height: sourceCanvas.height });
|
|
console.log('Selection in container space:', { startX, startY, endX, endY, width, height });
|
|
|
|
// Get the canvas bounding rect to find where it's positioned within the container
|
|
const canvasRect = sourceCanvas.getBoundingClientRect();
|
|
const canvasOffsetX = canvasRect.left - containerRect.left;
|
|
const canvasOffsetY = canvasRect.top - containerRect.top;
|
|
|
|
console.log('Canvas position in container:', { offsetX: canvasOffsetX, offsetY: canvasOffsetY });
|
|
console.log('Canvas display size:', { width: canvasRect.width, height: canvasRect.height });
|
|
|
|
// Calculate selection relative to the canvas element
|
|
const selectionRelativeToCanvas = {
|
|
x: startX - canvasOffsetX,
|
|
y: startY - canvasOffsetY,
|
|
width: width,
|
|
height: height
|
|
};
|
|
|
|
console.log('Selection relative to canvas:', selectionRelativeToCanvas);
|
|
|
|
// Calculate the scale factor between canvas display size and actual pixel size
|
|
// The canvas may be rendered at higher resolution (devicePixelRatio)
|
|
const scaleX = sourceCanvas.width / canvasRect.width;
|
|
const scaleY = sourceCanvas.height / canvasRect.height;
|
|
|
|
console.log('Canvas scale factors:', { scaleX, scaleY });
|
|
|
|
// Convert selection coordinates to canvas pixel coordinates
|
|
const canvasX = Math.round(selectionRelativeToCanvas.x * scaleX);
|
|
const canvasY = Math.round(selectionRelativeToCanvas.y * scaleY);
|
|
const canvasWidth = Math.round(selectionRelativeToCanvas.width * scaleX);
|
|
const canvasHeight = Math.round(selectionRelativeToCanvas.height * scaleY);
|
|
|
|
console.log('Selection in canvas pixel space:', { canvasX, canvasY, canvasWidth, canvasHeight });
|
|
|
|
// Ensure coordinates are within canvas bounds
|
|
const safeX = Math.max(0, Math.min(canvasX, sourceCanvas.width));
|
|
const safeY = Math.max(0, Math.min(canvasY, sourceCanvas.height));
|
|
const safeWidth = Math.max(0, Math.min(canvasWidth, sourceCanvas.width - safeX));
|
|
const safeHeight = Math.max(0, Math.min(canvasHeight, sourceCanvas.height - safeY));
|
|
|
|
console.log('Safe coordinates:', { safeX, safeY, safeWidth, safeHeight });
|
|
|
|
if (safeWidth === 0 || safeHeight === 0) {
|
|
console.warn('Selection is outside canvas bounds');
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
// Extract the selected region from the source canvas
|
|
const ctx = sourceCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new Error('Could not get canvas context');
|
|
}
|
|
|
|
const imageData = ctx.getImageData(safeX, safeY, safeWidth, safeHeight);
|
|
|
|
// Create a new canvas for the selected region
|
|
const selectedCanvas = document.createElement('canvas');
|
|
selectedCanvas.width = safeWidth;
|
|
selectedCanvas.height = safeHeight;
|
|
|
|
const selectedCtx = selectedCanvas.getContext('2d');
|
|
if (!selectedCtx) {
|
|
throw new Error('Could not create selection canvas context');
|
|
}
|
|
|
|
selectedCtx.putImageData(imageData, 0, 0);
|
|
|
|
// Convert selected canvas to blob
|
|
const screenshot = await new Promise<Blob>((resolve, reject) => {
|
|
selectedCanvas.toBlob((blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
} else {
|
|
reject(new Error('Failed to create blob from canvas'));
|
|
}
|
|
}, 'image/jpeg', 0.98);
|
|
});
|
|
|
|
console.log('Screenshot created successfully');
|
|
|
|
// Extract text from the selected area using OCR
|
|
let extractedText = '';
|
|
try {
|
|
extractedText = await performOCR(screenshot, authToken);
|
|
} catch (ocrError) {
|
|
console.error('OCR extraction failed:', ocrError);
|
|
}
|
|
|
|
onSelectionComplete(screenshot, extractedText);
|
|
} catch (error) {
|
|
console.error('Failed to process selection:', error);
|
|
onCancel();
|
|
}
|
|
};
|
|
|
|
// Render PDF to canvas at specified scale
|
|
const renderPDFToCanvas = async (
|
|
pdfBlob: Blob,
|
|
pageNumber: number,
|
|
canvas: HTMLCanvasElement,
|
|
containerWidth: number,
|
|
containerHeight: number,
|
|
renderScale: number,
|
|
zoomLevel: number = 1.0
|
|
): Promise<{ offsetX: number; offsetY: number; pageScale: number; viewport: any }> => {
|
|
const pdfData = await pdfBlob.arrayBuffer();
|
|
const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
|
|
|
|
if (pageNumber < 1 || pageNumber > pdf.numPages) {
|
|
throw new Error(`Invalid page number: ${pageNumber}`);
|
|
}
|
|
|
|
const page = await pdf.getPage(pageNumber);
|
|
|
|
// Calculate the scale needed to render the PDF page to match the layout in container
|
|
// We want the same aspect ratio and positioning as in the PDF viewer
|
|
const originalViewport = page.getViewport({ scale: 1 });
|
|
|
|
// Calculate scale factors to fit page within container while preserving aspect ratio
|
|
const scaleX = containerWidth / originalViewport.width;
|
|
const scaleY = containerHeight / originalViewport.height;
|
|
let pageScale = Math.min(scaleX, scaleY);
|
|
|
|
// Apply zoom level to the page scale
|
|
pageScale *= zoomLevel;
|
|
|
|
// Apply the render scale factor for high resolution
|
|
const finalScale = pageScale * renderScale;
|
|
|
|
// Create the viewport at this scale
|
|
const viewport = page.getViewport({ scale: finalScale });
|
|
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
// Return default values if context not available
|
|
return { offsetX: 0, offsetY: 0, pageScale: 1, viewport: originalViewport };
|
|
}
|
|
|
|
// Calculate offset to center the page in the canvas
|
|
const offsetX = Math.round((canvas.width - viewport.width) / 2);
|
|
const offsetY = Math.round((canvas.height - viewport.height) / 2);
|
|
|
|
// Render the page with anti-aliasing and smooth rendering for quality
|
|
const renderContext = {
|
|
canvasContext: context,
|
|
viewport: viewport,
|
|
transform: [1, 0, 0, 1, offsetX, offsetY],
|
|
intent: 'display' as const
|
|
};
|
|
|
|
// Render the page with improved rendering quality
|
|
await page.render(renderContext).promise;
|
|
|
|
return { offsetX, offsetY, pageScale, viewport };
|
|
};
|
|
|
|
// Perform OCR on the captured image
|
|
const performOCR = async (image: Blob, token: string): Promise<string> => {
|
|
const formData = new FormData();
|
|
formData.append('image', image);
|
|
|
|
const response = await fetch('/api/ocr/recognize', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to recognize text via OCR');
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.text;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!overlayCanvasRef.current || !startPoint || !currentPoint || !containerRef.current) return;
|
|
|
|
const canvas = overlayCanvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Match the canvas dimensions to the container
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
canvas.width = containerRect.width;
|
|
canvas.height = containerRect.height;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw selection rectangle
|
|
const x = Math.min(startPoint.x, currentPoint.x);
|
|
const y = Math.min(startPoint.y, currentPoint.y);
|
|
const width = Math.abs(currentPoint.x - startPoint.x);
|
|
const height = Math.abs(currentPoint.y - startPoint.y);
|
|
|
|
// Draw semi-transparent overlay
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Clear selection area
|
|
ctx.clearRect(x, y, width, height);
|
|
|
|
// Draw selection border
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(x, y, width, height);
|
|
|
|
// Draw corner handles
|
|
const handleSize = 8;
|
|
ctx.fillStyle = '#3b82f6';
|
|
ctx.fillRect(x - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
|
|
ctx.fillRect(x + width - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
|
|
ctx.fillRect(x - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
|
|
ctx.fillRect(x + width - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
|
|
}, [startPoint, currentPoint, containerRef]);
|
|
|
|
return (
|
|
<div
|
|
className="absolute inset-0 z-50 cursor-crosshair bg-white/20"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
>
|
|
<canvas
|
|
ref={overlayCanvasRef}
|
|
className="absolute inset-0 pointer-events-none"
|
|
/>
|
|
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-4 py-2 rounded-lg text-sm z-[60]">
|
|
{t('dragToSelect')}
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |