Files
aurak/web/components/PDFSelectionTool.tsx
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- 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
2026-04-23 17:19:11 +08:00

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