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 453 additions and 0 deletions
Showing only changes of commit cc249fb444 - Show all commits

View File

@@ -15,8 +15,12 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling).
### Changed
- "Scan image" button replaced with a 📷 floating action button at the bottom-right of the viewport (was a small button in the input pane header). Mobile thumb-friendly, doesn't crowd the input label.
- Crop modal has inner padding so cropperjs's corner handles stay away from the screen's right/bottom edges — fixes the bottom-right resize handle triggering Android's edge-swipe back gesture. Also respects iOS safe-area insets.
- "Scan image" button replaced with a floating action button at the bottom-right of the viewport, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header).
- Crop modal:
- No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing.
- Rotate left / Rotate right / Reset / Use this buttons replaced with Material Symbols icons (`rotate_left`, `rotate_right`, `restart_alt`, `check`). Cancel kept as text.
- Inner padding bumped to 2 rem so cropperjs's corner handles never sit on the right/bottom edges where Android's edge-swipe back gesture fires. iOS safe-area insets honoured on the backdrop and FAB.
- Header text is now a hint ("Drag on the image to select the text region") instead of a title.
### Fixed
- "Hot: …" status no longer claims "unloads in X" when the server's `keep_alive` is `-1` (Ollama returns an absurd year-4001 `expires_at`). Threshold: any expiry >7 days drops the suffix and just shows the model name.

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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" />
<style>
:root {
color-scheme: light dark;
@@ -156,6 +157,14 @@
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 button for OCR scan — bottom-right, mobile-thumb friendly */
.scan-fab {
position: fixed;
@@ -170,8 +179,6 @@
color: #fff;
border: none;
cursor: pointer;
font-size: 1.75rem;
line-height: 1;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
@@ -179,6 +186,7 @@
justify-content: center;
transition: transform 0.15s;
}
.scan-fab .material-symbols-outlined { font-size: 28px; }
.scan-fab:hover:not(:disabled) { transform: scale(1.06); }
.scan-fab:disabled { opacity: 0.6; cursor: progress; }
.scan-status {
@@ -223,8 +231,9 @@
min-height: 0;
max-height: 70vh;
background: #000;
/* Inset so cropper's corner handles never sit on a screen-edge gesture zone */
padding: 1.25rem;
/* 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 {
@@ -252,7 +261,13 @@
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.primary {
background: var(--accent);
@@ -328,7 +343,9 @@
<div id="meta" class="meta"></div>
</div>
<button type="button" id="scan-btn" class="scan-fab" aria-label="Scan image (OCR)" title="Scan image (OCR)">📷</button>
<button type="button" id="scan-btn" class="scan-fab" aria-label="Scan image (OCR)" title="Scan image (OCR)">
<span class="material-symbols-outlined">photo_camera</span>
</button>
<script type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
@@ -420,17 +437,17 @@
modal.className = "modal";
const url = URL.createObjectURL(file);
modal.innerHTML = `
<div class="modal-header">Crop &amp; rotate before OCR</div>
<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" data-act="rotL">Rotate left</button>
<button class="modal-btn" data-act="rotR">Rotate right</button>
<button class="modal-btn" data-act="reset">Reset</button>
<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="reset" title="Reset"><span class="material-symbols-outlined">restart_alt</span></button>
</div>
<div class="group">
<button class="modal-btn" data-act="cancel">Cancel</button>
<button class="modal-btn primary" data-act="use">Use this</button>
<button class="modal-btn primary icon" data-act="use" title="Use this"><span class="material-symbols-outlined">check</span></button>
</div>
</div>
`;
@@ -441,7 +458,8 @@
img.src = url;
const cropper = new Cropper(img, {
viewMode: 1,
autoCropArea: 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,
@@ -467,13 +485,15 @@
else if (act === "reset") cropper.reset();
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({
// Hand off something Tesseract can chew on without being huge.
// preprocessForOCR will downscale further if needed.
maxWidth: OCR_MAX_DIM * 2,
maxHeight: OCR_MAX_DIM * 2,
});
canvas.toBlob(blob => finish(blob), "image/png");
if (canvas) canvas.toBlob(blob => finish(blob), "image/png");
else finish(null);
}
}