Files
finrod/static/index.html
João Pedro Battistella Nadas b4990e1729 ux: drop page header, move Finrod wordmark into the drawer
The h1 + tagline at the top of the main view were just taking
vertical space. Strip them. The "Finrod" wordmark now lives only
inside the side drawer, set in Cinzel (classical-inscription
serif, loaded from Google Fonts) alongside the Star of Fëanor
mark — matches the favicon and the loremaster vibe.

Body padding-top adjusted to 3.5 rem so the controls clear the
fixed hamburger button at the top-left.
2026-06-09 20:09:36 +02:00

1286 lines
45 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>finrod — translator</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cg fill='%23d4a566'%3E%3Cpath d='M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z'/%3E%3Cpath d='M16 4 L22 16 L16 28 L10 16 Z' opacity='.55'/%3E%3C/g%3E%3C/svg%3E" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=block" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&display=swap" />
<style>
:root {
color-scheme: light dark;
--bg: #faf8f3;
--fg: #2b2620;
--muted: #6b6358;
--accent: #7a5a2f;
--border: #d8d0bf;
--panel: #fff;
--error: #a13b2c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1a17;
--fg: #e6dfd0;
--muted: #968c7a;
--accent: #d4a566;
--border: #3a352e;
--panel: #25221d;
--error: #e08070;
}
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
margin: 0;
/* Top padding (~3.5 rem) clears the fixed hamburger button.
Bottom padding leaves room for the stacked FABs. */
padding: 3.5rem 1rem calc(9rem + env(safe-area-inset-bottom, 0px)) 1rem;
}
.wrap { max-width: 960px; margin: 0 auto; }
/* Cinzel wordmark — only used inside the side drawer */
.drawer-wordmark {
font-family: 'Cinzel', Georgia, serif;
font-size: 1.9rem;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--accent);
display: flex;
align-items: center;
gap: 0.55rem;
margin: 0;
text-transform: uppercase;
line-height: 1;
}
.drawer-wordmark svg { width: 26px; height: 26px; }
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
/* Hamburger menu (top-left) → slide-out side drawer that houses the
Model selector (and future per-app settings). */
.drawer-toggle {
position: fixed;
top: calc(1rem + env(safe-area-inset-top, 0px));
left: calc(1rem + env(safe-area-inset-left, 0px));
width: 40px;
height: 40px;
padding: 0;
margin: 0;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--panel);
color: var(--fg);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}
.drawer-toggle .material-symbols-outlined { font-size: 22px; }
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 200;
}
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(85vw, 320px);
background: var(--bg);
border-right: 1px solid var(--border);
padding:
max(1rem, env(safe-area-inset-top, 0px))
1.25rem
max(1rem, env(safe-area-inset-bottom, 0px))
max(1.25rem, env(safe-area-inset-left, 0px));
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 201;
box-shadow: 4px 0 14px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.drawer.open { transform: translateX(0); }
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.drawer-close {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
padding: 0;
margin: 0;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.drawer-close:hover { color: var(--fg); background: var(--panel); }
.drawer-close .material-symbols-outlined { font-size: 20px; }
.drawer label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.25rem;
}
label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.08em; }
select, textarea, button {
font: inherit;
color: var(--fg);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem 0.75rem;
width: 100%;
}
textarea { resize: vertical; min-height: 180px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.panes { grid-template-columns: 1fr; }
}
button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
padding: 0.7rem 1.5rem;
font-weight: 500;
letter-spacing: 0.05em;
width: auto;
margin: 1rem 0;
}
button:disabled { opacity: 0.5; cursor: progress; }
.output {
white-space: pre-wrap;
min-height: 180px;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.meta { font-size: 0.8rem; color: var(--muted); margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
.error { color: var(--error); }
.output.streaming {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 0 12px -4px var(--accent);
}
.caret {
display: inline-block;
width: 0.5em;
height: 1em;
vertical-align: text-bottom;
background: var(--accent);
margin-left: 1px;
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink { to { opacity: 0; } }
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
.hot-status {
font-size: 0.8rem;
color: var(--muted);
margin: 0.25rem 0 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.2em;
}
.hot-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #e07a3e;
box-shadow: 0 0 6px #e07a3e;
}
.hot-status.idle { opacity: 0.55; }
.hot-status.idle .hot-dot {
background: var(--border);
box-shadow: none;
}
/* Material Symbols Outlined — used for FAB camera, rotate, reset, check */
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
line-height: 1;
vertical-align: middle;
user-select: none;
}
/* Floating action buttons — stacked at bottom-right.
Translate is the primary action (closest to the thumb), Scan sits above. */
.fab {
position: fixed;
right: calc(1.5rem + env(safe-area-inset-right, 0px));
width: 56px;
height: 56px;
padding: 0;
margin: 0;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, box-shadow 0.15s;
}
.fab .material-symbols-outlined { font-size: 28px; }
.fab:hover:not(:disabled) { transform: scale(1.06); }
.fab:disabled { opacity: 0.55; cursor: progress; }
/* Android Chrome shrinks the layout viewport when the soft keyboard
opens (via `interactive-widget=resizes-content` on the viewport meta),
so plain `bottom: 1.5rem` is already above the keyboard. */
.fab-primary {
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
}
.fab-secondary {
/* sits above the primary: 56 (primary height) + 0.75rem gap + base offset */
bottom: calc(1.5rem + 56px + 0.75rem + env(safe-area-inset-bottom, 0px));
}
.fab.busy {
animation: fab-pulse 1.4s ease-in-out infinite;
}
@keyframes fab-pulse {
0%, 100% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }
50% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(224, 122, 62, 0.35); }
}
.scan-status {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.4rem;
min-height: 1em;
}
.scan-status.error { color: var(--error); }
/* Crop / rotate modal (cropperjs lives inside .modal-body) */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
justify-content: center;
/* Generous side padding pushes the modal in from Android's edge-swipe
gesture zone (~24dp from screen edges triggers back/home). */
padding: 1rem max(1rem, env(safe-area-inset-right, 0px)) 1rem max(1rem, env(safe-area-inset-left, 0px));
z-index: 1000;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
width: min(90vw, 900px);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
color: var(--muted);
letter-spacing: 0.04em;
}
.modal-body {
flex: 1;
min-height: 0;
max-height: 70vh;
background: #000;
/* Inset so cropper's corner handles never sit on a screen-edge gesture zone.
2 rem + backdrop 1 rem = ~48 px clear from screen edges on a phone. */
padding: 2rem;
}
.modal-body img { display: block; max-width: 100%; }
.modal-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.5rem;
justify-content: space-between;
flex-wrap: wrap;
}
.modal-footer .group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.modal-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 0.4rem 0.9rem;
border-radius: 4px;
cursor: pointer;
margin: 0;
width: auto;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.modal-btn .material-symbols-outlined { font-size: 20px; }
.modal-btn.icon { padding: 0.35rem 0.55rem; min-width: 2.4rem; }
.modal-btn:hover { border-color: var(--accent); color: var(--fg); }
.modal-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.modal-btn.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.modal-btn.primary.icon {
padding: 0.55rem 1.1rem;
min-width: 3.2rem;
}
.modal-btn.primary.icon .material-symbols-outlined { font-size: 24px; }
.modal-btn.primary:hover { color: #fff; }
/* Camera viewfinder modal */
.cam-body {
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
}
.cam-body video {
max-width: 100%;
max-height: 70vh;
display: block;
}
/* On mobile: full-bleed modal, buttons hug the screen edges. Body padding
still ~16 px so cropper corner handles stay outside Android's edge-swipe
gesture zone — tapping a button doesn't trigger it, but dragging into
the very edge does. */
@media (max-width: 640px) {
.modal-backdrop { padding: 0; }
.modal {
width: 100vw;
max-width: 100vw;
height: 100dvh; /* dynamic viewport — accounts for mobile browser chrome */
max-height: 100dvh;
border: 0;
border-radius: 0;
}
.modal-header {
flex-shrink: 0; /* never get squeezed out by the body */
padding-top: max(0.75rem, env(safe-area-inset-top, 0px));
}
.modal-body {
flex: 1 1 auto;
min-height: 0;
padding:
0.5rem
max(1rem, env(safe-area-inset-right, 0px))
0.5rem
max(1rem, env(safe-area-inset-left, 0px));
max-height: none;
}
.cam-body { padding: 0 !important; }
.cam-body video {
width: 100%;
height: 100%;
max-height: none;
object-fit: contain;
}
.modal-footer {
flex-shrink: 0;
background: var(--bg); /* explicit so it always reads clearly */
padding:
0.5rem
max(0.5rem, env(safe-area-inset-right, 0px))
max(0.5rem, env(safe-area-inset-bottom, 0px))
max(0.5rem, env(safe-area-inset-left, 0px));
}
}
/* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor {
min-height: 180px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1rem; /* match .output's inherited body size — CM6 defaults are smaller */
}
.cm-editor.cm-focused {
outline: none;
border-color: var(--accent);
}
.cm-scroller { padding: 0.6rem 0.75rem; }
.cm-content { caret-color: var(--accent); }
/* vim's command-line / status hint inherits readable colors */
.cm-vim-panel {
background: var(--panel);
color: var(--fg);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>
</head>
<body>
<button type="button" id="drawer-toggle" class="drawer-toggle" aria-label="Open menu" title="Menu">
<span class="material-symbols-outlined">menu</span>
</button>
<div id="drawer-backdrop" class="drawer-backdrop"></div>
<aside id="drawer" class="drawer" aria-hidden="true">
<div class="drawer-header">
<div class="drawer-wordmark">
<svg viewBox="0 0 32 32" aria-hidden="true">
<g fill="currentColor">
<path d="M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z"/>
<path d="M16 4 L22 16 L16 28 L10 16 Z" opacity=".55"/>
</g>
</svg>
<span>Finrod</span>
</div>
<button type="button" id="drawer-close" class="drawer-close" aria-label="Close menu" title="Close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div>
<label for="model">Model</label>
<select id="model"></select>
</div>
</aside>
<div class="wrap">
<div class="controls">
<div>
<label for="source">From</label>
<select id="source"></select>
</div>
<div>
<label for="target">To</label>
<select id="target"></select>
</div>
</div>
<div id="hot-status" class="hot-status idle">
<span class="hot-dot"></span>
<span id="hot-status-text">No model loaded</span>
</div>
<div class="panes">
<div>
<label for="input">Input</label>
<div id="input"></div>
<input type="file" id="scan-input" accept="image/*" hidden>
<div id="scan-status" class="scan-status"></div>
</div>
<div>
<label for="output">Translation</label>
<div id="output" class="output"></div>
</div>
</div>
<div id="meta" class="meta"></div>
</div>
<button type="button" id="scan-btn" class="fab fab-secondary" aria-label="Scan image (OCR)" title="Scan image (OCR)">
<span class="material-symbols-outlined">photo_camera</span>
</button>
<button type="button" id="go" class="fab fab-primary" aria-label="Translate" title="Translate (⌘/Ctrl + Enter)">
<span class="material-symbols-outlined">translate</span>
</button>
<script type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
import { EditorState } from "https://esm.sh/@codemirror/state@6";
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
"French", "German", "Italian", "Dutch", "Polish", "Russian",
"Japanese", "Korean", "Chinese (Simplified)", "Chinese (Traditional)",
"Arabic", "Hindi", "Turkish", "Swedish", "Norwegian", "Danish",
"Finnish", "Greek", "Hebrew", "Czech", "Hungarian", "Romanian",
"Ukrainian", "Vietnamese", "Thai", "Indonesian",
];
const sourceSel = document.getElementById("source");
const targetSel = document.getElementById("target");
const modelSel = document.getElementById("model");
const output = document.getElementById("output");
const goBtn = document.getElementById("go");
const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
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);
}
// Crop + rotate modal. Lazy-loads cropperjs + its CSS from esm.sh on
// first use. Resolves with a Blob (cropped, rotation baked in) or null
// if the user cancelled.
let cropperCssLoaded = false;
async function openCropper(file) {
if (!cropperCssLoaded) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://esm.sh/cropperjs@1/dist/cropper.min.css";
document.head.appendChild(link);
cropperCssLoaded = true;
}
const { default: Cropper } = await import("https://esm.sh/cropperjs@1");
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
const modal = document.createElement("div");
modal.className = "modal";
const url = URL.createObjectURL(file);
modal.innerHTML = `
<div class="modal-header">Drag on the image to select the text region</div>
<div class="modal-body"><img alt="" /></div>
<div class="modal-footer">
<div class="group">
<button class="modal-btn icon" data-act="rotL" title="Rotate left"><span class="material-symbols-outlined">rotate_left</span></button>
<button class="modal-btn icon" data-act="rotR" title="Rotate right"><span class="material-symbols-outlined">rotate_right</span></button>
<button class="modal-btn icon" data-act="invert" title="Invert colours (for light text on dark background)"><span class="material-symbols-outlined">invert_colors</span></button>
</div>
<div class="group">
<button class="modal-btn" data-act="cancel">Cancel</button>
<button class="modal-btn primary icon" data-act="use" title="Use this"><span class="material-symbols-outlined">check</span></button>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const img = modal.querySelector("img");
img.src = url;
const cropper = new Cropper(img, {
viewMode: 1,
// No default crop box — user draws their own by dragging on the image.
autoCrop: false,
background: false,
// Honour EXIF orientation from the phone shot.
checkOrientation: true,
});
let invert = false;
const cleanup = () => {
cropper.destroy();
URL.revokeObjectURL(url);
backdrop.remove();
document.removeEventListener("keydown", onKey);
};
const finish = (result) => { cleanup(); resolve(result); };
const onKey = (e) => {
if (e.key === "Escape") finish(null);
else if (e.key === "Enter") emit("use");
};
document.addEventListener("keydown", onKey);
function emit(act, btn) {
if (act === "rotL") cropper.rotate(-90);
else if (act === "rotR") cropper.rotate(90);
else if (act === "invert") {
invert = !invert;
btn?.classList.toggle("active", invert);
}
else if (act === "cancel") finish(null);
else if (act === "use") {
// With autoCrop:false, if the user never drew a region, force a
// default crop covering the full image so getCroppedCanvas() works.
if (!cropper.cropped) cropper.crop();
const canvas = cropper.getCroppedCanvas({
maxWidth: OCR_MAX_DIM * 2,
maxHeight: OCR_MAX_DIM * 2,
});
if (canvas) canvas.toBlob(blob => finish({ blob, invert }), "image/png");
else finish(null);
}
}
modal.addEventListener("click", (e) => {
const btn = e.target.closest("[data-act]");
if (btn) emit(btn.dataset.act, btn);
});
// Click on backdrop (outside the modal) = cancel
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) finish(null);
});
});
}
// Downscale to at most OCR_MAX_DIM on the long edge AND binarise with
// Bradley's adaptive threshold. Phone photos have gradients, shadows
// and texture that Tesseract trained on scanned pages doesn't expect,
// so it hallucinates text. Adaptive thresholding turns the input into
// a binary "ink vs paper" mask which is what the recogniser wants.
const OCR_MAX_DIM = 1600;
const OCR_CONFIDENCE_MIN = 60; // word-level confidence (0-100)
async function preprocessForOCR(file, { invert = false } = {}) {
const bitmap = await createImageBitmap(file);
const long = Math.max(bitmap.width, bitmap.height);
const scale = Math.min(1, OCR_MAX_DIM / long);
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close?.();
binarizeAdaptive(ctx, w, h, { invert });
// PNG, not JPEG — JPEG would re-blur the freshly-sharp B/W edges.
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
return { blob, w, h, scaled: scale < 1, origLong: long };
}
// Bradley/Roth adaptive threshold: compare each pixel against the
// mean of a local window. Computed in O(N) total via an integral
// image. Output is a 3-channel B/W image (R=G=B=0 or 255).
// invert=true flips the grayscale before thresholding so white-on-dark
// text comes out as the expected black-on-white for Tesseract.
function binarizeAdaptive(ctx, w, h, { invert = false } = {}) {
const imgData = ctx.getImageData(0, 0, w, h);
const data = imgData.data;
// 1. Grayscale via BT.601 luminance
const gray = new Uint8Array(w * h);
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
gray[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0;
}
if (invert) {
for (let i = 0; i < gray.length; i++) gray[i] = 255 - gray[i];
}
// 2. Integral image (summed-area table)
const integral = new Float64Array(w * h);
for (let y = 0; y < h; y++) {
let rowSum = 0;
const yw = y * w;
for (let x = 0; x < w; x++) {
rowSum += gray[yw + x];
integral[yw + x] = rowSum + (y > 0 ? integral[yw - w + x] : 0);
}
}
// 3. Threshold: pixel becomes black if it's > T% below the local mean
const winSize = Math.max(15, Math.floor(w / 32)) | 1; // odd window
const halfWin = (winSize - 1) >> 1;
const T = 0.15;
for (let y = 0; y < h; y++) {
const yw = y * w;
const y1 = Math.max(0, y - halfWin);
const y2 = Math.min(h - 1, y + halfWin);
for (let x = 0; x < w; x++) {
const x1 = Math.max(0, x - halfWin);
const x2 = Math.min(w - 1, x + halfWin);
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
const A = (x1 > 0 && y1 > 0) ? integral[(y1 - 1) * w + (x1 - 1)] : 0;
const B = (y1 > 0) ? integral[(y1 - 1) * w + x2] : 0;
const C = (x1 > 0) ? integral[y2 * w + (x1 - 1)] : 0;
const D = integral[y2 * w + x2];
const mean = (D - B - C + A) / count;
const black = gray[yw + x] < mean * (1 - T) ? 0 : 255;
const idx = (yw + x) * 4;
data[idx] = data[idx + 1] = data[idx + 2] = black;
}
}
ctx.putImageData(imgData, 0, 0);
}
// Drop words below confidence threshold; re-assemble preserving line breaks.
function filterByConfidence(result, threshold = OCR_CONFIDENCE_MIN) {
const lines = result.lines || [];
if (!lines.length) return result.text || "";
const keptLines = lines.map(line => {
const words = (line.words || [])
.filter(w => (w.confidence ?? 100) >= threshold)
.map(w => w.text);
return words.length ? words.join(" ") : "";
});
// Collapse consecutive blank lines so dropped runs don't leave gaps.
return keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
async function runOCR(file) {
const src = sourceSel.value;
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
scanBtn.disabled = true;
setScanStatus("Loading crop editor…");
let cropResult;
try {
cropResult = await openCropper(file);
} catch (e) {
console.error(e);
setScanStatus(`Crop editor failed: ${e.message || e}`, { error: true });
scanBtn.disabled = false;
scanInput.value = "";
return;
}
if (!cropResult) {
// User cancelled
setScanStatus("");
scanBtn.disabled = false;
scanInput.value = "";
return;
}
const { blob: cropped, invert } = cropResult;
setScanStatus("Preparing image…");
const t0 = performance.now();
try {
const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped, { invert });
if (scaled) {
setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`);
} else {
setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`);
}
// 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 } = await worker.recognize(blob);
await worker.terminate();
const text = filterByConfidence(data);
const droppedWords = (data.words || []).filter(w => (w.confidence ?? 100) < OCR_CONFIDENCE_MIN).length;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text },
selection: { anchor: 0 },
});
const dt = ((performance.now() - t0) / 1000).toFixed(1);
const dropped = droppedWords ? `, dropped ${droppedWords} low-confidence` : "";
setScanStatus(`OCR done in ${dt}s (${lang}${dropped})`);
} 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
}
}
// Live camera capture via getUserMedia — bypasses the OS camera's
// "retake / use" confirm step that <input type=file capture> shows.
// Tap the shutter → frame is captured and crop modal opens immediately.
// Returns the captured Blob, "file" if the user wants the file picker,
// or null on cancel.
async function captureFromCamera() {
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
audio: false,
});
} catch (e) {
console.warn("Camera unavailable:", e);
return "file"; // fall through to file picker
}
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
const modal = document.createElement("div");
modal.className = "modal";
modal.innerHTML = `
<div class="modal-header">Aim at the text and tap the shutter</div>
<div class="modal-body cam-body"><video autoplay playsinline muted></video></div>
<div class="modal-footer">
<div class="group">
<button class="modal-btn icon" data-act="file" title="Pick from file instead"><span class="material-symbols-outlined">photo_library</span></button>
</div>
<div class="group">
<button class="modal-btn" data-act="cancel">Cancel</button>
<button class="modal-btn primary icon" data-act="shoot" title="Capture"><span class="material-symbols-outlined">photo_camera</span></button>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const video = modal.querySelector("video");
video.srcObject = stream;
const cleanup = () => {
stream.getTracks().forEach(t => t.stop());
video.srcObject = null;
backdrop.remove();
document.removeEventListener("keydown", onKey);
};
const finish = (r) => { cleanup(); resolve(r); };
const onKey = (e) => {
if (e.key === "Escape") finish(null);
};
document.addEventListener("keydown", onKey);
modal.addEventListener("click", (e) => {
const act = e.target.closest("[data-act]")?.dataset.act;
if (act === "cancel") finish(null);
else if (act === "file") finish("file");
else if (act === "shoot") {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
canvas.toBlob(blob => finish(blob), "image/jpeg", 0.92);
}
});
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) finish(null);
});
});
}
async function startScan() {
// On a touch device with getUserMedia, go straight to the live
// viewfinder. On desktop / no-camera, use the file picker.
if (isCoarsePointer && navigator.mediaDevices?.getUserMedia) {
const result = await captureFromCamera();
if (result === "file") {
scanInput.click();
} else if (result) {
runOCR(result);
}
return;
}
scanInput.click();
}
scanBtn.addEventListener("click", startScan);
scanInput.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (file) runOCR(file);
});
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer;
const editor = new EditorView({
parent: document.getElementById("input"),
state: EditorState.create({
extensions: [
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
// takes precedence. Skipped on touch devices — modal editing without
// a hardware Esc key on a virtual keyboard is unusable, and IME
// composition fights the vim keymap.
...(isCoarsePointer ? [] : [vim()]),
history(),
keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } },
...defaultKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
placeholder("Type or paste text to translate…"),
EditorView.updateListener.of(u => {
if (u.docChanged) {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
}
}),
],
}),
});
const inputText = () => editor.state.doc.toString();
function fillLangs() {
const auto = document.createElement("option");
auto.value = "auto";
auto.textContent = "Auto-detect";
sourceSel.appendChild(auto);
for (const lang of LANGUAGES) {
const o1 = document.createElement("option");
o1.value = lang; o1.textContent = lang;
sourceSel.appendChild(o1);
const o2 = document.createElement("option");
o2.value = lang; o2.textContent = lang;
targetSel.appendChild(o2);
}
sourceSel.value = "Dutch";
targetSel.value = "English";
}
async function loadModels() {
try {
const r = await fetch("/api/models");
if (!r.ok) throw new Error(await r.text());
const models = await r.json();
if (models.length === 0) {
const o = document.createElement("option");
o.textContent = "(no matching models on Ollama)";
o.disabled = true;
modelSel.appendChild(o);
return;
}
for (const m of models) {
const o = document.createElement("option");
o.value = m; o.textContent = m;
modelSel.appendChild(o);
}
} catch (e) {
meta.innerHTML = `<span class="error">Could not load models: ${e}</span>`;
}
}
function setMeta({ phase, model, seconds, tokens }) {
meta.innerHTML = "";
const indicator = document.createElement("span");
if (phase === "waiting") indicator.className = "spinner";
else if (phase === "streaming") indicator.className = "pulse";
else indicator.style.display = "none";
meta.appendChild(indicator);
const label = document.createElement("span");
const parts = [];
if (phase === "waiting") parts.push("Waking model…");
else if (phase === "streaming") parts.push("Translating…");
parts.push(model);
if (seconds != null) parts.push(`${seconds.toFixed(1)}s`);
if (tokens) parts.push(`${tokens} tok`);
label.textContent = parts.join(" · ");
meta.appendChild(label);
}
async function translate() {
const text = inputText().trim();
if (!text) return;
goBtn.disabled = true;
goBtn.classList.add("busy");
output.textContent = "";
output.classList.add("streaming");
const model = modelSel.value;
const t0 = performance.now();
let firstToken = true;
let tokens = 0;
let acc = "";
const ticker = setInterval(() => {
const dt = (performance.now() - t0) / 1000;
setMeta({
phase: firstToken ? "waiting" : "streaming",
model,
seconds: dt,
tokens: firstToken ? 0 : tokens,
});
}, 100);
setMeta({ phase: "waiting", model, seconds: 0 });
try {
const r = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
source: sourceSel.value,
target: targetSel.value,
model,
}),
});
if (!r.ok) throw new Error(await r.text());
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buf = "";
const caret = '<span class="caret"></span>';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let j;
try { j = JSON.parse(line); } catch { continue; }
if (j.error) throw new Error(j.error);
if (j.response) {
if (firstToken) firstToken = false;
acc += j.response;
tokens++;
output.innerHTML =
acc.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])) + caret;
}
}
}
const dt = (performance.now() - t0) / 1000;
output.textContent = acc;
clearInterval(ticker);
setMeta({ phase: "done", model, seconds: dt, tokens });
refreshLoaded();
} catch (e) {
clearInterval(ticker);
output.innerHTML = `<span class="error">${e.message || e}</span>`;
meta.textContent = "";
} finally {
output.classList.remove("streaming");
goBtn.disabled = false;
goBtn.classList.remove("busy");
}
}
function formatRemaining(expiresAt) {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (!isFinite(ms) || ms <= 0) return null;
// Ollama's keep_alive=-1 returns expires_at far in the future (year 4001).
// Any realistic finite keep_alive is hours at most, so treat >7 days
// as "never unloads" and drop the misleading suffix.
if (ms > 7 * 24 * 60 * 60 * 1000) return null;
const totalSec = Math.floor(ms / 1000);
const mins = Math.floor(totalSec / 60);
const secs = totalSec % 60;
if (mins >= 60) {
const hrs = Math.floor(mins / 60);
const m = mins % 60;
return `${hrs}h${m ? m + "m" : ""}`;
}
if (mins > 0) return `${mins}m${secs ? secs + "s" : ""}`;
return `${secs}s`;
}
async function refreshLoaded(isInitial = false) {
let models = [];
try {
const r = await fetch("/api/loaded");
if (r.ok) {
const j = await r.json();
models = j.models || [];
}
} catch {
/* leave previous state if Ollama is briefly unreachable */
return;
}
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
// Update dropdown options. Use textContent (option doesn't render HTML).
const optionValues = Array.from(modelSel.options, o => o.value);
for (const opt of modelSel.options) {
if (loadedMap.has(opt.value)) {
opt.textContent = `${opt.value}`;
} else {
opt.textContent = opt.value;
}
}
// On the very first refresh after page load, prefer a hot model as the
// dropdown default — skipping disk reload pain. Only on initial load,
// so the user's manual selection isn't yanked away while they type.
if (isInitial) {
const hot = models.find(m => optionValues.includes(m.name));
if (hot) modelSel.value = hot.name;
}
// Update status line.
if (models.length === 0) {
hotStatus.classList.add("idle");
hotStatusText.textContent = "No model loaded";
return;
}
hotStatus.classList.remove("idle");
const parts = models.map(m => {
const remaining = formatRemaining(m.expires_at);
return remaining ? `${m.name} — unloads in ${remaining}` : m.name;
});
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
}
goBtn.addEventListener("click", translate);
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
// Tab). Two paths kept in sync:
// 1. Document keydown listener — fires when focus is on a select / button
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
// Together they make Shift-H/L work everywhere, including escaping the editor.
const focusables = [
{ el: sourceSel, focus: () => sourceSel.focus() },
{ el: targetSel, focus: () => targetSel.focus() },
{ el: modelSel, focus: () => modelSel.focus() },
{ el: editor.contentDOM, focus: () => editor.focus() },
{ el: goBtn, focus: () => goBtn.focus() },
];
function moveFocus(delta) {
const ae = document.activeElement;
let idx = focusables.findIndex(f => f.el === ae || f.el.contains?.(ae));
if (idx === -1) {
focusables[delta > 0 ? 0 : focusables.length - 1].focus();
return;
}
const next = focusables[(idx + delta + focusables.length) % focusables.length];
next.focus();
}
document.addEventListener("keydown", (e) => {
if (!e.shiftKey) return;
const k = e.key.toLowerCase();
if (k !== "h" && k !== "l") return;
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
// the event bubble here would double-jump.
if (editor.dom.contains(document.activeElement)) return;
e.preventDefault();
moveFocus(k === "l" ? 1 : -1);
});
// Override vim's H/L (normally "viewport top/bottom") so they exit the
// editor to the previous/next control, matching the Shift-H/L contract
// the document handler uses elsewhere.
if (!isCoarsePointer) {
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
Vim.defineAction("finrodFocusNext", () => moveFocus(1));
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
}
// Side drawer (Settings) wiring — opens from the top-left hamburger.
const drawerEl = document.getElementById("drawer");
const drawerBackdrop = document.getElementById("drawer-backdrop");
const drawerToggle = document.getElementById("drawer-toggle");
const drawerCloseBtn = document.getElementById("drawer-close");
function openDrawer() {
drawerEl.classList.add("open");
drawerBackdrop.classList.add("open");
drawerEl.setAttribute("aria-hidden", "false");
}
function closeDrawer() {
drawerEl.classList.remove("open");
drawerBackdrop.classList.remove("open");
drawerEl.setAttribute("aria-hidden", "true");
}
drawerToggle.addEventListener("click", openDrawer);
drawerCloseBtn.addEventListener("click", closeDrawer);
drawerBackdrop.addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerEl.classList.contains("open")) closeDrawer();
});
fillLangs();
loadModels().then(() => refreshLoaded(true));
</script>
</body>
</html>