feat: implement region-based QR code scanning for inbox documents
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
This commit is contained in:
@@ -93,6 +93,14 @@ export const inboxApi = {
|
||||
api
|
||||
.post(`/api/inbox/${encodeURIComponent(id)}/source`, { source })
|
||||
.then((r) => r.data),
|
||||
|
||||
scanRegion: (id: string, page: number, region: { x: number; y: number; w: number; h: number }) =>
|
||||
api
|
||||
.post<{ found: string[] }>(
|
||||
`/api/inbox/${encodeURIComponent(id)}/pages/${page}/scan-region`,
|
||||
region,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface PostprocessActionResult {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Empty, Modal, Popconfirm, Space, Spin, Steps, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderOpenOutlined,
|
||||
LeftOutlined,
|
||||
LoadingOutlined,
|
||||
QrcodeOutlined,
|
||||
RedoOutlined,
|
||||
RightOutlined,
|
||||
ThunderboltOutlined,
|
||||
@@ -508,9 +509,15 @@ export default function InboxDetailPage() {
|
||||
const [selectedPage, setSelectedPage] = useState(1);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [scanMode, setScanMode] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<{ clientX: number; clientY: number; relX: number; relY: number } | null>(null);
|
||||
const [dragEnd, setDragEnd] = useState<{ relX: number; relY: number } | null>(null);
|
||||
|
||||
const thumbsRef = useRef<Map<number, string>>(new Map());
|
||||
const previewRef = useRef<string | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rotationFor = (page: number): number =>
|
||||
file?.rotations?.[String(page)] ?? 0;
|
||||
@@ -686,6 +693,84 @@ export default function InboxDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toNormalizedImageCoords = useCallback((clientX: number, clientY: number) => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return null;
|
||||
const rect = img.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dx = clientX - cx;
|
||||
const dy = clientY - cy;
|
||||
const dxU = dx / zoom;
|
||||
const dyU = dy / zoom;
|
||||
const Rrad = currentRotation * Math.PI / 180;
|
||||
const cosR = Math.cos(Rrad);
|
||||
const sinR = Math.sin(Rrad);
|
||||
const dxUr = dxU * cosR + dyU * sinR;
|
||||
const dyUr = -dxU * sinR + dyU * cosR;
|
||||
const rW = (currentRotation === 90 || currentRotation === 270) ? rect.height / zoom : rect.width / zoom;
|
||||
const rH = (currentRotation === 90 || currentRotation === 270) ? rect.width / zoom : rect.height / zoom;
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (dxUr + rW / 2) / rW)),
|
||||
y: Math.max(0, Math.min(1, (dyUr + rH / 2) / rH)),
|
||||
};
|
||||
}, [zoom, currentRotation]);
|
||||
|
||||
const handleOverlayMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const overlayRect = e.currentTarget.getBoundingClientRect();
|
||||
setDragStart({ clientX: e.clientX, clientY: e.clientY, relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top });
|
||||
setDragEnd(null);
|
||||
}, []);
|
||||
|
||||
const handleOverlayMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dragStart) return;
|
||||
const overlayRect = e.currentTarget.getBoundingClientRect();
|
||||
setDragEnd({ relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top });
|
||||
}, [dragStart]);
|
||||
|
||||
const handleOverlayMouseUp = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dragStart || !file) { setDragStart(null); setDragEnd(null); return; }
|
||||
const overlayRect = e.currentTarget.getBoundingClientRect();
|
||||
const end = { clientX: e.clientX, clientY: e.clientY, relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top };
|
||||
setDragStart(null);
|
||||
setDragEnd(null);
|
||||
setScanMode(false);
|
||||
|
||||
const p1 = toNormalizedImageCoords(dragStart.clientX, dragStart.clientY);
|
||||
const p2 = toNormalizedImageCoords(end.clientX, end.clientY);
|
||||
if (!p1 || !p2) return;
|
||||
|
||||
const rx = Math.min(p1.x, p2.x);
|
||||
const ry = Math.min(p1.y, p2.y);
|
||||
const rw = Math.abs(p2.x - p1.x);
|
||||
const rh = Math.abs(p2.y - p1.y);
|
||||
if (rw < 0.01 || rh < 0.01) { message.warning('Bitte einen größeren Bereich auswählen'); return; }
|
||||
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await inboxApi.scanRegion(file.id, selectedPage, { x: rx, y: ry, w: rw, h: rh });
|
||||
if (result.found.length > 0) {
|
||||
message.success(`QR-Code gefunden: ${result.found.join(', ')}`);
|
||||
const list = await inboxApi.list();
|
||||
const refreshed = list.find((f) => f.id === file.id) ?? null;
|
||||
if (refreshed) setFile(refreshed);
|
||||
} else {
|
||||
message.warning('Kein QR-Code in diesem Bereich gefunden');
|
||||
}
|
||||
} catch {
|
||||
message.error('QR-Bereich-Scan fehlgeschlagen');
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [dragStart, file, selectedPage, toNormalizedImageCoords]);
|
||||
|
||||
const handleOverlayMouseLeave = useCallback(() => {
|
||||
setDragStart(null);
|
||||
setDragEnd(null);
|
||||
setScanMode(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
let cancelled = false;
|
||||
@@ -1085,6 +1170,19 @@ export default function InboxDetailPage() {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
|
||||
<Tooltip title={scanMode ? 'Bereich-Scan abbrechen' : 'QR-Code in Bereich scannen'}>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={scanning
|
||||
? <LoadingOutlined style={{ fontSize: 16 }} />
|
||||
: <QrcodeOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={() => { setScanMode((v) => !v); setDragStart(null); setDragEnd(null); }}
|
||||
disabled={scanning}
|
||||
style={{ color: scanMode ? '#52c41a' : '#fff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1100,6 +1198,7 @@ export default function InboxDetailPage() {
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={previewUrl}
|
||||
alt={`Vorschau Seite ${selectedPage}`}
|
||||
style={{
|
||||
@@ -1117,6 +1216,37 @@ export default function InboxDetailPage() {
|
||||
<Spin />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay für QR-Bereich-Scan */}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: scanMode || dragStart ? 5 : -1,
|
||||
cursor: scanMode ? 'crosshair' : 'default',
|
||||
pointerEvents: scanMode ? 'all' : 'none',
|
||||
}}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onMouseMove={handleOverlayMouseMove}
|
||||
onMouseUp={handleOverlayMouseUp}
|
||||
onMouseLeave={handleOverlayMouseLeave}
|
||||
>
|
||||
{dragStart && dragEnd && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: Math.min(dragStart.relX, dragEnd.relX),
|
||||
top: Math.min(dragStart.relY, dragEnd.relY),
|
||||
width: Math.abs(dragEnd.relX - dragStart.relX),
|
||||
height: Math.abs(dragEnd.relY - dragStart.relY),
|
||||
border: '2px dashed #1677ff',
|
||||
background: 'rgba(22, 119, 255, 0.1)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user