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:
Claudio Schaad 2026-08-27 18:43:47 +02:00
parent 88d965a364
commit 3da4dec452
4 changed files with 222 additions and 5 deletions

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

View file

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

View file

@ -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>

View file

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