Compare commits
15 Commits
dcac01e51b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53796f3b03 | ||
| fd8f0b74aa | |||
|
|
713e3f1993 | ||
|
|
b4990e1729 | ||
|
|
1fffe8b7e1 | ||
|
|
bde042a459 | ||
|
|
86620f9f5c | ||
|
|
f160c0510b | ||
|
|
ffa133261d | ||
|
|
29226500e8 | ||
|
|
a63b11e467 | ||
|
|
cc249fb444 | ||
|
|
a4064d0d72 | ||
|
|
74b8877550 | ||
|
|
4d4e351860 |
@@ -8,3 +8,7 @@ __pycache__
|
||||
.mypy_cache
|
||||
README.md
|
||||
build.sh
|
||||
|
||||
# Rebuilt fresh in the frontend stage; never copy a local build into the image.
|
||||
frontend/node_modules
|
||||
static/vendor
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ __pycache__/
|
||||
.mypy_cache/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Frontend vendor bundle is produced at Docker build time (frontend/build.mjs).
|
||||
node_modules/
|
||||
static/vendor/
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -7,9 +7,44 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-07-05
|
||||
|
||||
### Changed
|
||||
- **All browser dependencies are now vendored into the image — nothing loads from a CDN at runtime.** A new `frontend/` esbuild step (run in a Node stage of the Dockerfile) bundles CodeMirror into a single self-contained `static/vendor/codemirror.js` and copies cropperjs + Tesseract.js (engine, wasm core, and the Dutch `nld` language pack) into `static/vendor/`. The page imports only from `/static/vendor/…`. Google Fonts (Material Symbols, Cinzel) is the one remaining external `<link>`; it degrades gracefully and isn't in the failing path.
|
||||
|
||||
### Fixed
|
||||
- Empty language and model dropdowns whenever esm.sh was degraded. The editor's top-level `import`s pulled `@codemirror/*` from esm.sh, whose transitive graph contained an **unpinned** `@codemirror/view@^6.27.0` range URL. During esm.sh's storage outage that range returned an error body as `text/plain`, so the browser blocked the module for a disallowed MIME type — and a single failed top-level import aborts the whole `<script type="module">`, so `fillLangs()`/`loadModels()` never ran despite a healthy backend. Vendoring removes every runtime CDN import, eliminating the failure mode entirely.
|
||||
|
||||
### Removed
|
||||
- Vim editing mode (`@replit/codemirror-vim`) and its `Vim.mapCommand` H/L bindings. The document-level Shift-H/L focus cycling across the dropdowns/buttons is retained. One fewer dependency to vendor.
|
||||
|
||||
## [0.3.0] - 2026-06-09
|
||||
|
||||
### 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.
|
||||
- Images are downscaled to 1600 px on the long edge before OCR. A 12 MP phone photo (~4000 px) drops to ~1.3 MP, making Tesseract 4-8× faster with no real accuracy loss for printed text. Smaller-than-1600 px inputs are passed through unchanged.
|
||||
- Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on.
|
||||
- Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped.
|
||||
- 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, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header).
|
||||
- "Translate" inline button replaced with a primary FAB using the `translate` Material Symbols icon, stacked directly under the camera FAB at the bottom-right corner (closest to the thumb on phones). The camera FAB is now the *secondary* in the stack, just above. Both 56 × 56 px, same accent fill. The translate FAB gains a pulsing-ring `busy` animation during a running translation; `goBtn.textContent` swap is gone (was meaningless for an icon-only button). `body` got `~9 rem` of bottom padding so content scrolls clear of the stack.
|
||||
- FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android).
|
||||
- Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model).
|
||||
- Model selector moved into a slide-out side drawer (hamburger menu in the top-left). Drawer opens from the left with a backdrop, closes via the X button, backdrop click, or `Esc`. Honours Android safe-area insets.
|
||||
- Page header (`h1 finrod` + tagline) removed from the main view to recover vertical space. 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.
|
||||
- Default source language is now Dutch (was Auto-detect). Target stays English.
|
||||
- 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.
|
||||
- Buttons are now Material Symbols icons: `rotate_left`, `rotate_right`, `invert_colors` (new — see below), `check`. Cancel kept as text. The `check` (Use this) button is larger and more prominent. The Reset button was removed (rarely useful with no default box).
|
||||
- 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.
|
||||
|
||||
### Added
|
||||
- Camera viewfinder modal on mobile (via `navigator.mediaDevices.getUserMedia` with `facingMode: environment`) — replaces the `<input type=file capture>` flow. Tapping the shutter goes directly to the crop modal, skipping the OS-level "retake / use" confirm step. Falls back to the file picker if camera access is denied or unavailable; a "Pick from file" icon (`photo_library`) inside the camera modal also routes to the picker on demand.
|
||||
- Invert-colours toggle (`invert_colors`) in the crop modal. Tap to flip light-on-dark text into the dark-on-light polarity Tesseract expects. The grayscale buffer is inverted before adaptive thresholding so the binarised output is correct.
|
||||
- Mobile-first modal layout (≤ 640 px): the crop and camera modals now fill the viewport (no backdrop padding, no border-radius), buttons hug the screen edges in the footer, and the camera video occupies the entire body. Body still has ~20 px horizontal padding so cropper's corner handles stay outside Android's edge-swipe back-gesture zone. iOS notch / home-bar insets honoured.
|
||||
|
||||
### 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.
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -1,5 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Frontend stage: vendor all browser dependencies (CodeMirror, cropperjs,
|
||||
# Tesseract.js + wasm core + the Dutch language pack) into static/vendor so the
|
||||
# running container serves everything locally — no esm.sh / CDN at runtime.
|
||||
# Pinned to the build platform: the output is architecture-neutral browser
|
||||
# assets, so there's no need to re-run it under emulation for each target arch.
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN node build.mjs
|
||||
|
||||
# Build stage: install deps into a venv with uv.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||
|
||||
@@ -22,6 +34,7 @@ WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY app.py ./
|
||||
COPY static ./static
|
||||
COPY --from=frontend /app/static/vendor ./static/vendor
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
77
frontend/build.mjs
Normal file
77
frontend/build.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
// Vendors finrod's browser dependencies into ../static/vendor/ so the running
|
||||
// container serves everything locally — no esm.sh / CDN dependency at runtime.
|
||||
//
|
||||
// node build.mjs
|
||||
//
|
||||
// Outputs:
|
||||
// static/vendor/codemirror.js — esbuild bundle (view+state+commands)
|
||||
// static/vendor/cropper.esm.js — cropperjs ESM (dependency-free)
|
||||
// static/vendor/cropper.min.css — cropperjs stylesheet
|
||||
// static/vendor/tesseract/tesseract.esm.min.js — Tesseract.js main thread
|
||||
// static/vendor/tesseract/worker.min.js — Tesseract.js worker
|
||||
// static/vendor/tesseract/tesseract-core*.{js,wasm} — wasm OCR core
|
||||
// static/vendor/tesseract/lang/<lang>.traineddata.gz — default language packs
|
||||
import * as esbuild from "esbuild";
|
||||
import { createRequire } from "node:module";
|
||||
import { cp, mkdir, rm, readdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = join(here, "..", "static", "vendor");
|
||||
|
||||
// Only Dutch is baked in — finrod's real use is OCR'ing Dutch source text.
|
||||
// Any other OCR language falls back to on-demand download from Tesseract's
|
||||
// default CDN. These packs are served locally but only fetched by the browser
|
||||
// when OCR is actually used (the OCR code path dynamic-imports Tesseract).
|
||||
const LANGS = ["nld"];
|
||||
const TESSDATA_BASE = "https://tessdata.projectnaptha.com/4.0.0";
|
||||
|
||||
const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
|
||||
|
||||
await rm(OUT, { recursive: true, force: true });
|
||||
await mkdir(join(OUT, "tesseract", "lang"), { recursive: true });
|
||||
|
||||
// 1. CodeMirror — bundle the whole graph into one ESM file.
|
||||
await esbuild.build({
|
||||
entryPoints: [join(here, "src", "codemirror.js")],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
outfile: join(OUT, "codemirror.js"),
|
||||
});
|
||||
console.log("✓ codemirror.js");
|
||||
|
||||
// 2. cropperjs — ESM build + CSS, both dependency-free, just copy.
|
||||
const cropper = pkgDir("cropperjs");
|
||||
await cp(join(cropper, "dist", "cropper.esm.js"), join(OUT, "cropper.esm.js"));
|
||||
await cp(join(cropper, "dist", "cropper.min.css"), join(OUT, "cropper.min.css"));
|
||||
console.log("✓ cropperjs");
|
||||
|
||||
// 3. Tesseract.js — ship the prebuilt worker + wasm core (do NOT bundle; it
|
||||
// spawns a Web Worker and streams wasm, which expects real dist files).
|
||||
const tess = pkgDir("tesseract.js");
|
||||
await cp(join(tess, "dist", "tesseract.esm.min.js"), join(OUT, "tesseract", "tesseract.esm.min.js"));
|
||||
await cp(join(tess, "dist", "worker.min.js"), join(OUT, "tesseract", "worker.min.js"));
|
||||
|
||||
const core = pkgDir("tesseract.js-core");
|
||||
for (const f of await readdir(core)) {
|
||||
if (f.endsWith(".js") || f.endsWith(".wasm")) {
|
||||
await cp(join(core, f), join(OUT, "tesseract", f));
|
||||
}
|
||||
}
|
||||
console.log("✓ tesseract engine + core");
|
||||
|
||||
// Language packs — fetched at build time so the runtime image is self-contained.
|
||||
for (const lang of LANGS) {
|
||||
const url = `${TESSDATA_BASE}/${lang}.traineddata.gz`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(join(OUT, "tesseract", "lang", `${lang}.traineddata.gz`), buf);
|
||||
console.log(`✓ tessdata ${lang} (${(buf.length / 1e6).toFixed(1)} MB)`);
|
||||
}
|
||||
|
||||
console.log("vendor build complete →", OUT);
|
||||
702
frontend/package-lock.json
generated
Normal file
702
frontend/package-lock.json
generated
Normal file
@@ -0,0 +1,702 @@
|
||||
{
|
||||
"name": "finrod-frontend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "finrod-frontend",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.7.1",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.36.1",
|
||||
"cropperjs": "1.6.2",
|
||||
"tesseract.js": "5.1.1",
|
||||
"tesseract.js-core": "5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz",
|
||||
"integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
|
||||
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.1.tgz",
|
||||
"integrity": "sha512-miD1nyT4m4uopZaDdO2uXU/LLHliKNYL9kB1C1wJHrunHLm/rpkb5QVSokqgw9hFqEZakrdlb/VGWX8aYZTslQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.6",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz",
|
||||
"integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/is-electron": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
|
||||
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/opencollective-postinstall": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencollective-postinstall": "index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz",
|
||||
"integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-electron": "^2.2.2",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^5.1.1",
|
||||
"wasm-feature-detect": "^1.2.11",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz",
|
||||
"integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "finrod-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Vendors finrod's browser dependencies into static/vendor so nothing loads from a CDN at runtime.",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.7.1",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.36.1",
|
||||
"cropperjs": "1.6.2",
|
||||
"tesseract.js": "5.1.1",
|
||||
"tesseract.js-core": "5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.24.2"
|
||||
}
|
||||
}
|
||||
6
frontend/src/codemirror.js
Normal file
6
frontend/src/codemirror.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Single entry point re-exporting every CodeMirror symbol index.html needs.
|
||||
// esbuild bundles this (and the whole transitive @codemirror/@lezer graph)
|
||||
// into one self-contained ESM file — no runtime CDN, no semver-range URLs.
|
||||
export { EditorView, keymap, placeholder } from "@codemirror/view";
|
||||
export { EditorState } from "@codemirror/state";
|
||||
export { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "finrod"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
description = "Translator UI backed by Ollama"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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;
|
||||
@@ -33,23 +35,119 @@
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
margin: 0;
|
||||
padding: 2rem 1rem;
|
||||
/* 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; }
|
||||
h1 {
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.25rem 0;
|
||||
/* 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;
|
||||
}
|
||||
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
|
||||
.drawer-wordmark svg { width: 26px; height: 26px; }
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.controls { grid-template-columns: 1fr; }
|
||||
|
||||
/* 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 {
|
||||
@@ -156,32 +254,55 @@
|
||||
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;
|
||||
/* 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;
|
||||
}
|
||||
.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;
|
||||
|
||||
/* 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: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
width: auto;
|
||||
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;
|
||||
}
|
||||
.scan-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--fg);
|
||||
.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-btn:disabled { cursor: progress; opacity: 0.6; }
|
||||
.scan-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
@@ -190,6 +311,154 @@
|
||||
}
|
||||
.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;
|
||||
@@ -205,21 +474,35 @@
|
||||
}
|
||||
.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">
|
||||
<h1>finrod</h1>
|
||||
<div class="sub">Translation via Ollama · loremaster of tongues</div>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label for="source">From</label>
|
||||
@@ -229,10 +512,6 @@
|
||||
<label for="target">To</label>
|
||||
<select id="target"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="model">Model</label>
|
||||
<select id="model"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hot-status" class="hot-status idle">
|
||||
@@ -242,12 +521,9 @@
|
||||
|
||||
<div class="panes">
|
||||
<div>
|
||||
<div class="input-header">
|
||||
<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>
|
||||
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="scan-input" accept="image/*" hidden>
|
||||
<div id="scan-status" class="scan-status"></div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -256,15 +532,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="go">Translate</button>
|
||||
<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";
|
||||
import {
|
||||
EditorView, keymap, placeholder,
|
||||
EditorState,
|
||||
history, defaultKeymap, historyKeymap,
|
||||
} from "/static/vendor/codemirror.js";
|
||||
|
||||
const LANGUAGES = [
|
||||
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
||||
@@ -287,7 +570,7 @@
|
||||
const scanInput = document.getElementById("scan-input");
|
||||
const scanStatus = document.getElementById("scan-status");
|
||||
|
||||
// --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) --------
|
||||
// --- OCR via Tesseract.js (vendored; lazy-loaded on first use) ----------
|
||||
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
|
||||
const TESS_LANGS = {
|
||||
"English": "eng",
|
||||
@@ -329,30 +612,199 @@
|
||||
scanStatus.classList.toggle("error", error);
|
||||
}
|
||||
|
||||
// Downscale to at most OCR_MAX_DIM on the long edge. A typical phone
|
||||
// photo is 4000+ px on the long edge — Tesseract is 4-8× faster at
|
||||
// 1600 px with no real accuracy loss for printed text.
|
||||
// Crop + rotate modal. Lazy-loads the vendored cropperjs + its CSS 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 = "/static/vendor/cropper.min.css";
|
||||
document.head.appendChild(link);
|
||||
cropperCssLoaded = true;
|
||||
}
|
||||
const { default: Cropper } = await import("/static/vendor/cropper.esm.js");
|
||||
|
||||
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;
|
||||
async function downscaleForOCR(file) {
|
||||
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);
|
||||
if (long <= OCR_MAX_DIM) {
|
||||
bitmap.close?.();
|
||||
return { blob: file, w: bitmap.width, h: bitmap.height, scaled: false };
|
||||
}
|
||||
const scale = OCR_MAX_DIM / long;
|
||||
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?.();
|
||||
const blob = await new Promise(resolve =>
|
||||
canvas.toBlob(resolve, "image/jpeg", 0.85)
|
||||
);
|
||||
return { blob, w, h, scaled: true, origLong: long };
|
||||
|
||||
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) {
|
||||
@@ -360,19 +812,47 @@
|
||||
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 downscaleForOCR(file);
|
||||
const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped, { invert });
|
||||
if (scaled) {
|
||||
setScanStatus(`Downscaled ${origLong}px → ${Math.max(w, h)}px`);
|
||||
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");
|
||||
// Lazy import — the vendored engine, wasm core, and language pack are
|
||||
// only fetched from the container on first scan, then browser-cached.
|
||||
// Everything is served locally (workerPath/corePath/langPath) so OCR
|
||||
// never depends on a CDN. Only the Dutch pack (nld) is bundled.
|
||||
const { createWorker } = await import("/static/vendor/tesseract/tesseract.esm.min.js");
|
||||
const worker = await createWorker(lang, 1, {
|
||||
workerPath: "/static/vendor/tesseract/worker.min.js",
|
||||
corePath: "/static/vendor/tesseract",
|
||||
langPath: "/static/vendor/tesseract/lang",
|
||||
logger: (m) => {
|
||||
if (m.status === "recognizing text") {
|
||||
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
|
||||
@@ -381,15 +861,18 @@
|
||||
}
|
||||
},
|
||||
});
|
||||
const { data: { text } } = await worker.recognize(blob);
|
||||
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.trim() },
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: text },
|
||||
selection: { anchor: 0 },
|
||||
});
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
setScanStatus(`OCR done in ${dt}s (${lang})`);
|
||||
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 });
|
||||
@@ -399,24 +882,105 @@
|
||||
}
|
||||
}
|
||||
|
||||
scanBtn.addEventListener("click", () => scanInput.click());
|
||||
// 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) ----------------
|
||||
// --- CodeMirror editor --------------------------------------------------
|
||||
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; } },
|
||||
@@ -449,7 +1013,7 @@
|
||||
o2.value = lang; o2.textContent = lang;
|
||||
targetSel.appendChild(o2);
|
||||
}
|
||||
sourceSel.value = "auto";
|
||||
sourceSel.value = "Dutch";
|
||||
targetSel.value = "English";
|
||||
}
|
||||
|
||||
@@ -498,7 +1062,7 @@
|
||||
const text = inputText().trim();
|
||||
if (!text) return;
|
||||
goBtn.disabled = true;
|
||||
goBtn.textContent = "Translating…";
|
||||
goBtn.classList.add("busy");
|
||||
output.textContent = "";
|
||||
output.classList.add("streaming");
|
||||
|
||||
@@ -570,7 +1134,7 @@
|
||||
} finally {
|
||||
output.classList.remove("streaming");
|
||||
goBtn.disabled = false;
|
||||
goBtn.textContent = "Translate";
|
||||
goBtn.classList.remove("busy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,10 +1208,8 @@
|
||||
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.
|
||||
// Tab), for focus on a select / button. Inside the editor they type
|
||||
// normally.
|
||||
const focusables = [
|
||||
{ el: sourceSel, focus: () => sourceSel.focus() },
|
||||
{ el: targetSel, focus: () => targetSel.focus() },
|
||||
@@ -669,22 +1231,33 @@
|
||||
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.
|
||||
// Inside the editor, let H/L type normally.
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user