v0.3.0: OCR via camera, FAB UX, side drawer #4

Merged
jpnadas merged 16 commits from feature/ocr-camera into main 2026-06-09 18:38:34 +00:00
2 changed files with 133 additions and 1 deletions
Showing only changes of commit 123ce59595 - Show all commits

View File

@@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- "Scan image" button next to the input pane: opens the phone's rear camera (or a file picker on desktop), runs OCR locally in the browser via Tesseract.js (loaded from esm.sh on first use), and replaces the editor contents with the recognised text. Language follows the "From" dropdown via an ISO 639-2 mapping; "Auto-detect" loads a common European set (`eng+por+deu+nld`). Progress status shows beneath the input ("Loading OCR engine…" → "OCR 40%" → "OCR done in 3.2s"). Zero image-size cost — same CDN pattern as CodeMirror.
## [0.2.0] - 2026-06-09 ## [0.2.0] - 2026-06-09
### Added ### Added

View File

@@ -156,6 +156,40 @@
box-shadow: none; box-shadow: none;
} }
/* Input-pane header: label on the left, "Scan image" button on the right */
.input-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.input-header label { margin-bottom: 0; }
.scan-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
margin: 0;
border-radius: 4px;
cursor: pointer;
letter-spacing: 0.04em;
text-transform: uppercase;
width: auto;
}
.scan-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--fg);
}
.scan-btn:disabled { cursor: progress; opacity: 0.6; }
.scan-status {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.4rem;
min-height: 1em;
}
.scan-status.error { color: var(--error); }
/* CodeMirror editor host — matches .output styling so input & output align */ /* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor { .cm-editor {
min-height: 180px; min-height: 180px;
@@ -208,8 +242,13 @@
<div class="panes"> <div class="panes">
<div> <div>
<div class="input-header">
<label for="input">Input</label> <label for="input">Input</label>
<button type="button" id="scan-btn" class="scan-btn" title="Extract text from a camera shot or image file">Scan image</button>
</div>
<div id="input"></div> <div id="input"></div>
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
<div id="scan-status" class="scan-status"></div>
</div> </div>
<div> <div>
<label for="output">Translation</label> <label for="output">Translation</label>
@@ -244,6 +283,96 @@
const meta = document.getElementById("meta"); const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status"); const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text"); const hotStatusText = document.getElementById("hot-status-text");
const scanBtn = document.getElementById("scan-btn");
const scanInput = document.getElementById("scan-input");
const scanStatus = document.getElementById("scan-status");
// --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) --------
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
const TESS_LANGS = {
"English": "eng",
"Portuguese (Brazil)": "por",
"Portuguese (Portugal)": "por",
"Spanish": "spa",
"French": "fra",
"German": "deu",
"Italian": "ita",
"Dutch": "nld",
"Polish": "pol",
"Russian": "rus",
"Japanese": "jpn",
"Korean": "kor",
"Chinese (Simplified)": "chi_sim",
"Chinese (Traditional)": "chi_tra",
"Arabic": "ara",
"Hindi": "hin",
"Turkish": "tur",
"Swedish": "swe",
"Norwegian": "nor",
"Danish": "dan",
"Finnish": "fin",
"Greek": "ell",
"Hebrew": "heb",
"Czech": "ces",
"Hungarian": "hun",
"Romanian": "ron",
"Ukrainian": "ukr",
"Vietnamese": "vie",
"Thai": "tha",
"Indonesian": "ind",
};
// "Auto-detect" — load common European set together; tesseract picks best.
const TESS_AUTO = "eng+por+deu+nld";
function setScanStatus(text, { error = false } = {}) {
scanStatus.textContent = text;
scanStatus.classList.toggle("error", error);
}
async function runOCR(file) {
const src = sourceSel.value;
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
scanBtn.disabled = true;
setScanStatus("Loading OCR engine…");
const t0 = performance.now();
try {
// Lazy import — first scan triggers the ~5 MB JS + language pack
// download; cached by the browser after that.
const { createWorker } = await import("https://esm.sh/tesseract.js@5");
const worker = await createWorker(lang, 1, {
logger: (m) => {
if (m.status === "recognizing text") {
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
} else if (m.status) {
setScanStatus(`OCR: ${m.status}`);
}
},
});
const { data: { text } } = await worker.recognize(file);
await worker.terminate();
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text.trim() },
selection: { anchor: 0 },
});
const dt = ((performance.now() - t0) / 1000).toFixed(1);
setScanStatus(`OCR done in ${dt}s (${lang})`);
} catch (e) {
console.error(e);
setScanStatus(`OCR failed: ${e.message || e}`, { error: true });
} finally {
scanBtn.disabled = false;
scanInput.value = ""; // allow re-selecting the same file
}
}
scanBtn.addEventListener("click", () => scanInput.click());
scanInput.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (file) runOCR(file);
});
// --- CodeMirror editor (vim mode on real keyboards only) ---------------- // --- CodeMirror editor (vim mode on real keyboards only) ----------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches; const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;