diff --git a/README.md b/README.md index ab434d3..438fa8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Notes -A small PWA for jotting down short notes while reading — e.g. an interesting sentence from a book. Each note can optionally be tagged with a book/topic (existing or new) and a page number. Notes are saved as markdown directly into pages of a Nextcloud **Collective**, one page per book/topic, nested under a parent page (default: "Reading Notes"). Untagged notes are appended to that parent page itself. +A small PWA for jotting down short notes while reading — e.g. an interesting sentence from a book. Each note is tagged with a book/topic (existing or new) and can optionally include a page number. Notes are saved as markdown directly into pages of a Nextcloud **Collective**, one page per book/topic, nested under a parent page (default: "Reading Notes"). A note's text can be typed by hand or filled in by scanning a photo of the page (on-device OCR via `tesseract.js` — no image or text ever leaves the device for this step). ## Stack diff --git a/client/package.json b/client/package.json index 50cc9de..7fa3603 100644 --- a/client/package.json +++ b/client/package.json @@ -12,7 +12,8 @@ "@notes/shared": "*", "@tanstack/react-query": "^5.51.23", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tesseract.js": "^7.0.0" }, "devDependencies": { "@types/react": "^18.3.3", diff --git a/client/src/lib/ocr.ts b/client/src/lib/ocr.ts new file mode 100644 index 0000000..3bf5701 --- /dev/null +++ b/client/src/lib/ocr.ts @@ -0,0 +1,23 @@ +let workerPromise: Promise | null = null; + +function getWorker() { + if (!workerPromise) { + workerPromise = import("tesseract.js").then(({ createWorker }) => createWorker("eng")); + } + return workerPromise; +} + +function cleanUpText(raw: string): string { + return raw + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export async function recognizeText(file: File): Promise { + const worker = await getWorker(); + const { + data: { text } + } = await worker.recognize(file); + return cleanUpText(text); +} diff --git a/client/src/pages/NoteFormPage.tsx b/client/src/pages/NoteFormPage.tsx index 0ad7595..470bef7 100644 --- a/client/src/pages/NoteFormPage.tsx +++ b/client/src/pages/NoteFormPage.tsx @@ -1,8 +1,9 @@ -import { useState, type FormEvent } from "react"; +import { useRef, useState, type ChangeEvent, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchBooks } from "../api/books"; import { saveNote } from "../api/notes"; import { logout } from "../api/auth"; +import { recognizeText } from "../lib/ocr"; const NEW_BOOK_OPTION = "__new__"; @@ -15,6 +16,9 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) { const [newBookTitle, setNewBookTitle] = useState(""); const [page, setPage] = useState(""); const [text, setText] = useState(""); + const [isScanning, setIsScanning] = useState(false); + const [scanError, setScanError] = useState(null); + const fileInputRef = useRef(null); const saveNoteMutation = useMutation({ mutationFn: saveNote, @@ -41,6 +45,23 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) { } } + async function handleImageSelected(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setIsScanning(true); + setScanError(null); + try { + const recognized = await recognizeText(file); + setText((prev) => (prev.trim() ? `${prev}\n${recognized}` : recognized)); + } catch { + setScanError("Couldn't recognize text in that photo. Try again with better lighting/focus."); + } finally { + setIsScanning(false); + e.target.value = ""; + } + } + function handleSubmit(e: FormEvent) { e.preventDefault(); const trimmedBook = (isAddingNewBook ? newBookTitle : book).trim(); @@ -65,7 +86,25 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {