Let the user crop the scanned photo before OCR
Whole-page photos carry far more text than the one sentence someone wants, hurting OCR accuracy and letting stray text leak into notes. Adds a crop overlay (drag-to-select via Pointer Events, works for touch and mouse) between capture and OCR, with a "use full photo" escape hatch for already-tight shots. recognizeText now takes a Blob so it accepts either the cropped canvas output or the original file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C3DGg1pvmvf81zXs4pk1J7
This commit is contained in:
parent
88d965a364
commit
3da4dec452
4 changed files with 222 additions and 5 deletions
150
client/src/components/ImageCropper.tsx
Normal file
150
client/src/components/ImageCropper.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
|
||||||
|
type Rect = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
const MIN_RECT_SIZE = 12;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cropToBlob(img: HTMLImageElement, rect: Rect): Promise<Blob> {
|
||||||
|
const scaleX = img.naturalWidth / img.clientWidth;
|
||||||
|
const scaleY = img.naturalHeight / img.clientHeight;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.round(rect.w * scaleX);
|
||||||
|
canvas.height = Math.round(rect.h * scaleY);
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Could not create canvas context");
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
rect.x * scaleX,
|
||||||
|
rect.y * scaleY,
|
||||||
|
rect.w * scaleX,
|
||||||
|
rect.h * scaleY,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => (blob ? resolve(blob) : reject(new Error("Could not crop image"))),
|
||||||
|
"image/jpeg",
|
||||||
|
0.92
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCropper({
|
||||||
|
imageFile,
|
||||||
|
onCancel,
|
||||||
|
onCropped
|
||||||
|
}: {
|
||||||
|
imageFile: File;
|
||||||
|
onCancel: () => void;
|
||||||
|
onCropped: (image: Blob) => void;
|
||||||
|
}) {
|
||||||
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
const [rect, setRect] = useState<Rect | null>(null);
|
||||||
|
const [isCropping, setIsCropping] = useState(false);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = URL.createObjectURL(imageFile);
|
||||||
|
setObjectUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [imageFile]);
|
||||||
|
|
||||||
|
function pointFromEvent(e: ReactPointerEvent) {
|
||||||
|
const bounds = wrapRef.current!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: clamp(e.clientX - bounds.left, 0, bounds.width),
|
||||||
|
y: clamp(e.clientY - bounds.top, 0, bounds.height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: ReactPointerEvent) {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
dragStart.current = pointFromEvent(e);
|
||||||
|
setRect({ x: dragStart.current.x, y: dragStart.current.y, w: 0, h: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: ReactPointerEvent) {
|
||||||
|
if (!dragStart.current) return;
|
||||||
|
const current = pointFromEvent(e);
|
||||||
|
const start = dragStart.current;
|
||||||
|
setRect({
|
||||||
|
x: Math.min(start.x, current.x),
|
||||||
|
y: Math.min(start.y, current.y),
|
||||||
|
w: Math.abs(current.x - start.x),
|
||||||
|
h: Math.abs(current.y - start.y)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
dragStart.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmCrop() {
|
||||||
|
if (!imgRef.current || !rect) return;
|
||||||
|
setIsCropping(true);
|
||||||
|
try {
|
||||||
|
const blob = await cropToBlob(imgRef.current, rect);
|
||||||
|
onCropped(blob);
|
||||||
|
} finally {
|
||||||
|
setIsCropping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasValidRect = !!rect && rect.w >= MIN_RECT_SIZE && rect.h >= MIN_RECT_SIZE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crop-overlay">
|
||||||
|
<div className="crop-dialog">
|
||||||
|
<p className="hint">Drag over the photo to select just the text you want.</p>
|
||||||
|
|
||||||
|
{objectUrl && (
|
||||||
|
<div
|
||||||
|
className="crop-image-wrap"
|
||||||
|
ref={wrapRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
>
|
||||||
|
<img ref={imgRef} src={objectUrl} alt="Captured page" draggable={false} />
|
||||||
|
{rect && (
|
||||||
|
<div
|
||||||
|
className="crop-rect"
|
||||||
|
style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="crop-actions">
|
||||||
|
<button type="button" className="link" onClick={onCancel} disabled={isCropping}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link"
|
||||||
|
onClick={() => onCropped(imageFile)}
|
||||||
|
disabled={isCropping}
|
||||||
|
>
|
||||||
|
Use full photo
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleConfirmCrop} disabled={!hasValidRect || isCropping}>
|
||||||
|
{isCropping ? "Cropping…" : "Confirm crop"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,10 @@ function cleanUpText(raw: string): string {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recognizeText(file: File): Promise<string> {
|
export async function recognizeText(image: Blob): Promise<string> {
|
||||||
const worker = await getWorker();
|
const worker = await getWorker();
|
||||||
const {
|
const {
|
||||||
data: { text }
|
data: { text }
|
||||||
} = await worker.recognize(file);
|
} = await worker.recognize(image);
|
||||||
return cleanUpText(text);
|
return cleanUpText(text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { fetchBooks } from "../api/books";
|
||||||
import { saveNote } from "../api/notes";
|
import { saveNote } from "../api/notes";
|
||||||
import { logout } from "../api/auth";
|
import { logout } from "../api/auth";
|
||||||
import { recognizeText } from "../lib/ocr";
|
import { recognizeText } from "../lib/ocr";
|
||||||
|
import { ImageCropper } from "../components/ImageCropper";
|
||||||
|
|
||||||
const NEW_BOOK_OPTION = "__new__";
|
const NEW_BOOK_OPTION = "__new__";
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [scanError, setScanError] = useState<string | null>(null);
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const saveNoteMutation = useMutation({
|
const saveNoteMutation = useMutation({
|
||||||
|
|
@ -45,20 +47,25 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImageSelected(e: ChangeEvent<HTMLInputElement>) {
|
function handleImageSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
setScanError(null);
|
||||||
|
setPendingImage(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCropped(image: Blob) {
|
||||||
|
setPendingImage(null);
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
setScanError(null);
|
setScanError(null);
|
||||||
try {
|
try {
|
||||||
const recognized = await recognizeText(file);
|
const recognized = await recognizeText(image);
|
||||||
setText((prev) => (prev.trim() ? `${prev}\n${recognized}` : recognized));
|
setText((prev) => (prev.trim() ? `${prev}\n${recognized}` : recognized));
|
||||||
} catch {
|
} catch {
|
||||||
setScanError("Couldn't recognize text in that photo. Try again with better lighting/focus.");
|
setScanError("Couldn't recognize text in that photo. Try again with better lighting/focus.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsScanning(false);
|
setIsScanning(false);
|
||||||
e.target.value = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,6 +84,14 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="centered">
|
<main className="centered">
|
||||||
|
{pendingImage && (
|
||||||
|
<ImageCropper
|
||||||
|
imageFile={pendingImage}
|
||||||
|
onCancel={() => setPendingImage(null)}
|
||||||
|
onCropped={handleCropped}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="card" onSubmit={handleSubmit}>
|
<form className="card" onSubmit={handleSubmit}>
|
||||||
<div className="header-row">
|
<div className="header-row">
|
||||||
<h1>New note</h1>
|
<h1>New note</h1>
|
||||||
|
|
|
||||||
|
|
@ -129,3 +129,55 @@ button.link {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crop-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-image-wrap img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-rect {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
background: rgba(31, 111, 235, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue